# Working with Leads: KISS system memory

_Last substantive update: 2026-08-29._

| | |
|---|---|
| **Audience** | An LLM or a new engineer who needs a complete, accurate model of how leads work at KISS |
| **Human page** | `/playbooks/working-with-leads` |
| **Owning systems** | HubSpot portal `49584170` (Leads object, `0-136`), the lead-creating n8n workflows, S3 (demo lock) |
| **Last reviewed** | 2026-08-26 |
| **Related** | `/playbooks/working-with-quotes` (what happens after a deal exists) |

---

## 1. The object model

Four HubSpot objects carry the sales motion. Keeping them distinct is the whole point.

| Object | What it represents | Lifespan |
|---|---|---|
| **Contact** (`0-1`) | A person. 16,852 exist. | Permanent |
| **Lead** (`0-136`) | **One attempt to start a sales conversation with one contact.** | Days to weeks, then closed |
| **Deal** (`0-3`) | A forecastable opportunity with a value. 859 exist. | Weeks to months |
| **Company** (`0-2`) | The storage operator. 10,954 exist, 79 with `locks_purchased > 0`. | Permanent |

A lead is a **work item**, not a person, and not a status on a person. A contact can accumulate many leads over years; each is one bounded attempt with its own owner, stages, and recorded outcome. This is why a returning prospect gets a *new* lead rather than a reopened one: reopening destroys the outcome record of the first attempt.

Qualification facts live on the **company**, never copied onto the lead:

- `number_of_facilities` (contact)
- `facility_management_software` (company), which PMS they run
- `current_lock_system` (company)
- `locks_purchased` (company). **Calculated**, it rolls up from closed-won deals. This is the authoritative "are they already a customer" signal.

## 2. The pipeline

Pipeline `lead-pipeline-id`, label "Lead pipeline". Each stage carries a `leadState` drawn from a fixed HubSpot set, which is the real constraint on stage design: **HubSpot allows exactly one `NEW`, one `QUALIFIED`, and one `UNQUALIFIED` stage. Only `IN_PROGRESS` is extensible.**

| # | Label | Stage id | `leadState` | `isClosed` |
|---|---|---|---|---|
| 1 | New | `new-stage-id` | `NEW` | false |
| 2 | Attempting | `attempting-stage-id` | `IN_PROGRESS` | false |
| 3 | Connected | `connected-stage-id` | `IN_PROGRESS` | false |
| 4 | Meeting Booked | `qualified-stage-id` | `QUALIFIED` | **true** |
| 5 | Disqualified | `unqualified-stage-id` | `UNQUALIFIED` | **true** |

Design intent: **every transition is externally verifiable.** Did a touch go out, did a human reply, did they book. No stage requires a rep to judge how promising someone feels, which is what keeps the board consistent across reps. "Meeting Booked" is deliberately named for the observable event rather than "Qualified", which is a judgement.

Two rules that are definitional rather than structural:

- **Connected requires a human-written reply.** Auto-replies, out-of-office notices, and bounces do *not* qualify. They return the lead to Attempting with the next step pushed out.
- **New should empty itself.** When a sequence sends the first touch, automation moves the lead to Attempting. A rep clicking "Attempting" after an automated send is unrewarded data entry, and this object has already been abandoned twice for feeling exactly like that.

There is deliberately **no Nurture or Timing stage.** Timing is a disqualification reason. A holding stage becomes a dead-letter bucket, which is empirically what happened to Connected (see §6).

## 3. Lead to deal handoff

**Single trigger: a meeting is booked with someone who could buy.**

1. Create the deal **from the lead record**.
2. The deal opens in the **Deal Pipeline** (`930425200`) at `Evaluation` (`dealstage = 1428879448`). The old Sales Pipeline's `New Opportunity` is retired (2026-10-01).
3. The lead closes as Meeting Booked.

Deals must not be created from a contact, a company, or from scratch. The lead-to-deal link is the only thing that attributes revenue back to a channel; a deal created standalone is permanently unattributable.

Rationale for drawing the line at the meeting: a deal carries a value and a close date and feeds the forecast. Before a meeting there is nothing to forecast, so an earlier handoff pollutes the deal pipeline with unqualified volume.

After the deal exists, see `/playbooks/working-with-quotes`.

## 4. Properties that matter

Writability was read from `modificationMetadata` on 2026-08-26. This matters: two of these look configurable and are not.

| Property | Type | Writable? | Notes |
|---|---|---|---|
| `hs_pipeline_stage` | enumeration | yes | The stage. See §2. |
| `hs_lead_disqualification_reason` | enumeration | **yes, options editable** | `readOnlyDefinition=false`. See §5. |
| `hs_lead_disqualification_note` | string | yes | Free text. Use for the callback date on Bad Timing. |
| `hs_lead_is_disqualified` | number | yes | HubSpot-maintained flag. |
| `hs_lead_type` | enumeration | **options editable, property fixed** | `readOnlyDefinition=true`. Now `NEW_BUSINESS` = "New business", `UPSELL` = "Existing business". `RE_ATTEMPTING` was removed 2026-08-26 as derivable from record history. |
| `hs_lead_label` | enumeration | **yes** | The de facto source/campaign field. See §5. |
| `hs_lead_source` | enumeration | **NO** | `readOnlyValue=true`, `readOnlyDefinition=true`. HubSpot web-analytics attribution. **Do not report on it**: for automation-created leads it reads `OFFLINE` (no web session) or `DIRECT_TRAFFIC` (unknown), neither of which is a sales source. |
| `hs_lead_source_drill_down_1` | string | **NO** | Locked, but informative as a *read*: distinguishes form submissions, imports, the browser extension, and meeting-link bookings. |
| `hubspot_owner_id` | enumeration | yes | **Must be set at creation.** See §6. |

154 properties exist on the object in total. Everything not listed above is HubSpot machinery and should stay off the record card.

## 5. Vocabularies

### Disqualification reason

HubSpot ships eight values. **None of them covers "no response"**, which is how most outbound attempts actually end, and that gap is the mechanical cause of the archiving problem in §6. Adding a `No Response` option is the single highest-leverage config change on this object.

| Value | Use |
|---|---|
| `NO_RESPONSE` *(to be added)* | Full sequence worked, no human ever replied |
| `NO_INTEREST` | They replied and declined |
| `NOT_A_GOOD_FIT` | Outside ICP |
| `BAD_TIMING` | Interested, wrong moment. Set a callback date in the note. |
| `NO_DECISION_MAKING_POWER` | Right company, wrong person |
| `BUDGET_CONSTRAINTS` | Wants it, cannot fund it |
| `COMPETITOR_PREFERENCE` | Rare; candidate to hide |
| `TECHNICAL_LIMITATIONS` | Rare; candidate to hide |
| `PRIOR_NEGATIVE_EXPERIENCE` | Rare; candidate to hide |

### Lead Channel (`hs_lead_label`)

The only writable source field on the object, and therefore the real attribution mechanism. Displayed as
**Lead Channel** — renamed from "Lead Label" on 2026-08-28 because *label* named nothing about what it holds. The
internal name cannot be changed.

**It answers exactly one question: WHERE did this lead come from — the door, not the programme.** The old
`Channel - Campaign` convention crammed both into one string (`Website - Demo Lock`). The programme half now lives
on `sent_date` on the contact, so the channel is free to mean one thing.

| Value | Means | Set by | Rolls up to the deal as |
|---|---|---|---|
| `Website` | They came to the site on their own | S6 | Inbound / Organic |
| `Marketing` | Our own broadcast reached them — mass email, organic social, content | S6 | Inbound / MKT Email Campaign |
| `Outbound - Rep` | One of our own reps found them | S6, rep | Outbound / Cold Outreach |
| `Outbound - IntentSignal` | Our hired cold-outreach vendor found them | S6 | Outbound / Cold Outreach |
| `Referral` | A customer or personal introduction, unpaid | rep | Referral / Organic |
| `Tradeshow` | An event: booth conversation or attendee list | rep | Outbound / Tradeshow |
| `Paid` | A bought placement — an ad | rep | Inbound / Paid Advertising |

**The prefix maps to the deal pair; the suffix names who did the work.** A second outreach vendor is one more
option and no new property, and the roll-up matches on the `Outbound - ` prefix.

⚠️ **IntentSignal is a hired B2B cold-outreach vendor, not a referral partner.** It is Outbound and shares a deal
roll-up with `Outbound - Rep`. The split exists purely so the invoice can be compared against our own team doing
the identical motion. `Partner - <Name>` is reserved for a genuine reseller or referral partner; nothing has
produced a lead through one yet.

#### `Website` vs `Marketing` — the split that matters most

**`Website` means they came on their own. `Marketing` means we reached them and they responded.**

This is not a fine distinction. One mass email with a demo-lock CTA produced **9 of the first 9 `Website` leads**
in August 2026. Lumped together, the website looked like a productive inbound channel; it has in fact produced
**zero** organic demo-lock leads to date. `Website` is now a genuinely empty baseline, so the first lead that
lands there is real inbound and you will know it.

⚠️ **Do not try to separate them from HubSpot's own first touch.** It proved the campaign for 7 of those 9 and
read `IMPORT` / `INTEGRATION` for the other 2, because first touch is overwritten the moment we create the
contact ourselves. That is exactly why this has to be a stored value rather than a report.

#### They clicked a CTA in a mass email or a LinkedIn post. Which is it?

`Marketing` if the click came from our broadcast, `Website` if they arrived under their own steam — and the only
reliable way for the system to know is **a campaign-specific demo-lock link**. Mint a `demo_request_partners` row
with `lead_label = 'Marketing'` and put that URL in the CTA; the token forces the label server-side, so the door
itself knows. ⚠️ Raise `daily_cap` from its default of 10 first: that send produced 7 submissions inside two
minutes and the 11th would have been turned away with a 429.

Which *specific* campaign belongs in HubSpot Campaigns, not in this field. Campaigns aggregate across doors; a
channel value cannot.

#### The rep-touched-them-first case

A rep DMs someone, they click through and fill the website form. The door says inbound, but a rep did the work.
**The system already handles it**: S6 stamps a channel on a live lead only when it has none, so a lead already
sitting at `Outbound - Rep` keeps that value when the same person later submits the form. Raise the lead before
sending the DM and the attribution stays honest.

**Do not** add an `Existing Customer` channel. That fact is carried by `hs_lead_type = UPSELL`, and encoding it
twice guarantees the two will disagree.

**Do not** add phone / email / LinkedIn to the outbound values. S6 cannot derive it (the request payload carries
no channel), outbound is a sequence rather than a single touch, and it has nowhere to roll up — the deal side has
one value for all of it. If it ever matters, it is a separate optional `outbound_channel` property, not a longer
channel value. Revisit past ~50 rep-sourced leads; there are 4 today.

### Lead Source (`hs_lead_source`) — native, and unusable

⚠️ **It is calculated and read-only.** A write comes back *"hs_lead_source is a calculated property; its value
cannot be set."* It was never an option.

It also could not answer the question. It carries HubSpot's web-traffic taxonomy, so it splits one channel three
ways — IntentSignal's 18 leads land in `DIRECT_TRAFFIC` (7), `OFFLINE` (10) and `REFERRALS` (1) — and it reads
`OFFLINE` for the hired vendor and our own reps alike. Keep it as context on a single record; never report on it.
⚠️ Never *display* our field as "Lead Source" either: that name is taken by this one.

## 6. Current state, 2026-08-26

Read directly from the object via a scoped service key. These numbers describe a system that has been started twice and abandoned twice.

- **102 lead records have ever existed.** 4 live, 98 archived.
- **Two attempts:** 67 leads across September and October 2025, then 35 on 25 and 26 August 2026.
- **Every archived lead died mid-pipeline:** New 24, Attempting 28, Connected 46. **Zero ever reached Meeting Booked.**
- **Zero disqualification reasons have ever been recorded**, across all 102 records.
- **62 of 98 archived leads had no owner.**
- Creation source: `AUTOMATION_PLATFORM` 87, `CRM_UI` 14, `INTERNAL_PROCESSING` 1.
- Meanwhile 59 deals sit in `New Opportunity`, none of which came from a lead.

The causal chain, which is the thing to preserve if this file is ever rewritten:

1. There is no reason value for "no response", so the most common real outcome has nowhere to go.
2. Reps therefore archive instead of disqualifying. Archiving hides the record and discards the outcome, which is why 98 records carry zero reasons.
3. No lead ever reaches the terminal win, so the object returns nothing to the person maintaining it.
4. An object that costs effort and returns nothing gets abandoned. Twice, so far.

### The demo lock gap

237 contacts have had a demo lock shipped (`demo_lock_type` populated; 236 have `demo_lock_address`, `sent_date`
now on 250). Requesting a physical lock is the highest-intent signal in the CRM.

**Closed, partially, on 2026-08-28.** Forty leads now exist: 18 `Outbound - IntentSignal`, 9 `Marketing`,
4 `Outbound - Rep`, 9 unlabelled (created by hand in the UI and awaiting their creator). Every new request raises
one through S6. Leads are named **`"<Full Name> lead"`** so they read differently from the contact for the same
person — HubSpot lists both in search and in association panels, and two identical rows is a coin-flip on which
you open.

The remaining ~207 are history and have not been backfilled. Whether any should enter the lead queue is a separate
decision, and the rule above is the test: only where no conversation is already running.

#### The demo-lock tag, and the two properties behind it

A lead tag surfaces which leads have a demo lock. `hs_tag_ids` on the Lead is **read-only** because tags are
condition-driven, so the tag's CONDITION reads the associated contact:

| Contact property | Means | Written by |
|---|---|---|
| `sent_date` — labelled **Demo Lock Requested Date** | they **asked for a lock** | S6, on every demo request. ⚠️ NOT on a calculator capture, which is not a lock request |
| `demo_lock_tracking` | a shipment **exists** | S3, only when EasyShip returns one |

⚠️ `sent_date` is **most-recent-wins** as of 2026-08-28 (it used to be first-wins). The question it answers is
*when did this person last have the product in their hand* — the hardware changes between revisions, so a 2025
date on somebody who asked again this morning sends a rep to talk about a lock that no longer exists. Nothing is
lost: a note is written on every path, so each request is on the timeline with its own date.

The website door asks **without shipping**, so the two genuinely differ. Do not collapse them.

⚠️ **A lead cannot be associated to an order.** `GET /crm/v4/associations/0-136/0-123/labels` returns 200 with
zero types, both directions. The contact is the bridge (lead→contact 578, order→contact 507). HubSpot links a lead
to its company by itself — a lead created with only 578 comes back carrying 580 Primary to the primary contact's
primary company.

### Not the Leads object

`os_last_lead_category_name` / `os_last_lead_category_id` on **contacts** (2,964 populated, 2,877 of them `campaign_completed_for_lead_without_reply`) belong to the prospecting and sequence machinery, **not** to this object. Verified: contacts carrying those categories, including one marked `lead_meeting_booked`, have zero associated lead records. Do not read those fields as lead metrics. Whether the two motions should converge is an open question and a separate decision.

`lead_origin` on contacts, `contact_source` and `lead_origin` on the Lead were **retired 2026-08-28** — all had zero records populated. `calling_source` was retired with the Cold Calling Intake form that fed it. ⚠️ A refused archive is HubSpot saying something depends on the property: read the error's `description`, not its generic `message`.

## 7. In progress

Done on 2026-08-28 and no longer open: S6 creates a labelled lead on every demo-lock request; `lead_origin`,
`contact_source` and `calling_source` retired; the channel vocabulary rebuilt; `sent_date` switched to
most-recent-wins and backfilled.

Still open:

- **Label the 9 hand-created leads.** They were made in the HubSpot UI, which runs none of S6's checks, so they
  carry no channel and do not follow the `"<Full Name> lead"` naming. Only their creator knows the channel.
- **Make Lead Channel required** (Settings → Objects → Leads). ⚠️ Not settable by API: `PATCH /crm/v3/schemas/0-136`
  answers *"Cannot modify HubSpot defined object type"*. Do it after the 9 are labelled, or it just blocks edits.
- Add `No Response` to `hs_lead_disqualification_reason`.
- Automate New → Attempting on first sequence send.
- Make `hubspot_owner_id` mandatory on every lead-creation path.
- Raise `daily_cap` on any `demo_request_partners` row used for a marketing campaign.
- Two people asked for a lock and could not be shipped one: **Scott Dunbar** (street with no city or ZIP) and
  **Benjamin Johnson** (a PO Box, which couriers will not take). Each needs one follow-up email.
- **Bradley Farnsworth** is an existing customer who asked for a demo lock on 2026-08-26 and never received one.
  Correctly has no lead — the rule says a customer is expansion, not a lead — but the request went unanswered.

## When this file is wrong, fix these

- **HubSpot portal `49584170`**: the Lead pipeline (`lead-pipeline-id`) and the `0-136` property definitions. Stage ids, `leadState` values, and `modificationMetadata` writability all come from here and are the first things to re-read.
- **The lead-creating n8n workflows**: 87 of 102 leads were created by `AUTOMATION_PLATFORM`, so owner and label stamping live there, not in a rep's hands.
- **S3**: the demo lock workflow, once it creates leads.
- **`reference_hubspot_api`** in the memory corpus, for credential and API-shape gotchas for this portal.
- **`/playbooks/working-with-quotes`**: the downstream half, from deal to signed quote.
