# HubSpot Admin Skills — full skill documentation

> All 37 skills, concatenated. Index: https://hubspot.granot.io/llms.txt
> Source: https://github.com/TomGranot/hubspot-admin-skills



---

<!-- skill: audit-api-usage | category: audit-planning | scripts: none -->

---
name: audit-api-usage
description: "Inventory the integrations, private apps, and internal tooling that call HubSpot APIs, and flag anything on legacy v1-v4 endpoints ahead of HubSpot's March 30, 2027 end of support. Produces a migration checklist to date-based API versions."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: audit-planning
---

# Audit API Usage Before the 2027 Version Cutoff

In March 2026 HubSpot replaced semantic API versioning (v1-v4) with date-based versions (`/2026-03/`-style paths, two releases a year, 18 months of support each). Everything still calling v1-v4 endpoints — including legacy OAuth v1 — becomes **unsupported on March 30, 2027**. This skill inventories what in your stack calls HubSpot and produces a migration checklist.

## Why This Matters

The deadline fails quietly: unsupported APIs "no longer receive updates, bug fixes, or stability guarantees," and marketplace apps that don't migrate risk losing certification. A portal typically has more callers than anyone remembers — private apps, marketplace apps, middleware (Zapier/Make), form embeds, internal scripts, data warehouse syncs. Finding them takes an afternoon now or an incident later.

## Key Facts

| Fact | Detail |
|------|--------|
| Current recommended version | `2026-03` |
| Release cadence | March and September, every year |
| Support lifecycle | Current (6 months) → Supported (to 18 months) → Unsupported |
| v1–v4 end of support | **March 30, 2027** |
| Also deprecated | Legacy v1 OAuth API (token issuance/introspection) |

## Prerequisites

- Super Admin access (to view Integrations settings and private app logs)
- Optionally: a private app token (`HUBSPOT_ACCESS_TOKEN` in `.env`) for the account-level usage query
- Access to the source code of any internal integrations

## Execution Pattern

### Stage 1: Plan

Confirm with the user: which systems are known to touch HubSpot (CRM syncs, website forms, analytics pipelines, internal scripts), and who owns each.

### Stage 2: Before — Build the Caller Inventory

Work through each discovery source and record every caller in a checklist (owner, what it does, endpoints/versions used):

1. **Private apps**: Settings > Integrations > Private Apps. Open each app > **Logs** — the API call log shows the exact request paths (`/crm/v3/...`, `/contacts/v1/...`), which reveal the version in use.
2. **Connected/marketplace apps**: Settings > Integrations > Connected Apps. You can't see their internals; note each vendor — certified apps are being pushed to migrate by HubSpot, but confirm with the vendor for anything business-critical.
3. **Internal codebases**: grep for version-revealing patterns:
   ```bash
   grep -rEn "api\.hubapi\.com/[a-z-]+/v[0-9]|/contacts/v1|/email/public/v1|hubapi.com/automation/v[23]" .
   ```
4. **Middleware** (Zapier, Make, Workato, n8n): platform-managed connectors migrate on the vendor's schedule; custom HTTP steps inside them do not — check those by hand.
5. **Account-wide API usage** (optional script check): the account-info API reports daily API usage totals for private apps:
   ```python
   resp = requests.get(f"{BASE}/account-info/v3/api-usage/daily", headers=HEADERS)
   ```
   This confirms *how much* traffic flows, not which versions — use the per-app logs from step 1 for version detail.

### Stage 3: Execute — Classify and Plan Migrations

For every caller, classify:

| Status | Meaning | Action |
|--------|---------|--------|
| **Red** | Calls v1/v2 legacy endpoints (e.g., `/contacts/v1/`, `/email/public/v1/`, forms v2) or legacy OAuth v1 | Migrate now — many of these had earlier sunset dates already |
| **Amber** | Calls v3/v4 endpoints | Works until 2027-03-30; schedule migration to `2026-03` date-based paths |
| **Green** | Calls date-based paths, or vendor-managed and confirmed migrating | Monitor |

For amber/red internal code, the migration is usually mechanical — same resources, new path prefix and (sometimes) renamed fields; consult the endpoint's migration notes in HubSpot's docs. Batch the work per codebase, not per endpoint.

This repo's own scripts are **amber** by design: they target `/crm/v3/` and `/automation/v4/`, supported until March 2027, with the migration path documented in `CONTRIBUTING.md`.

### Stage 4: After

1. Every caller in the inventory has a status and an owner.
2. Red items have migration tickets with dates; amber items are scheduled before 2027-03.
3. Re-run this audit every 6 months — each March/September release starts a new support clock.

## Rollback

Read-only — nothing to roll back.

## Technical Gotchas

1. **Private app logs are the ground truth.** Code greps miss dynamically-built URLs; the per-app request logs in Settings show what is actually being called.
2. **404 vs 403 vs unsupported.** After end of support, endpoints may keep working for a while — "unsupported" means no fixes or guarantees, not an instant shutoff. Do not treat "it still works" as "we're fine."
3. **OAuth is part of this.** Apps using legacy v1 OAuth token endpoints must move to the date-based OAuth API even if their data endpoints are current.
4. **SDK versions pin API versions.** If an integration uses an official HubSpot SDK, the SDK's major version determines which API version it calls — check the SDK changelog, not just your code.

---

<!-- skill: connect-hubspot-mcp | category: audit-planning | scripts: none -->

---
name: connect-hubspot-mcp
description: "Connect Claude Code to HubSpot's official remote MCP server for natural-language CRM reads and writes. Covers OAuth setup, scope selection, verification, and when to use MCP versus this repo's API scripts."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: audit-planning
---

# Connect Claude Code to HubSpot via MCP

Set up HubSpot's official remote MCP server so Claude can read and write CRM records conversationally — look up a contact, inspect a company's deals, spot-check the results of a cleanup — without writing a script for every question.

## What the MCP Server Is (and Isn't)

HubSpot's remote MCP server (generally available since April 2026) is an OAuth-secured gateway at `mcp.hubspot.com` that exposes CRM operations as tools to MCP clients like Claude Code:

- **Read/write CRM objects**: contacts, companies, deals, tickets, line items, products, engagements
- **Read content**: campaigns, landing pages, website pages, blog posts, and content analytics
- **Create content**: landing pages (added June 2026)

It is the right tool for **interactive, judgment-heavy work**: spot-checks, triage, one-off lookups, small targeted edits. It is the wrong tool for **bulk operations**: batch-archiving thousands of contacts, paginated sweeps, or anything that needs a CSV audit trail and an abort threshold. That is what this repo's scripts are for. The two share the same portal — use both.

| Task shape | Use |
|-----------|-----|
| "Show me 5 contacts the cleanup touched" | MCP |
| "Why is this company in Tier 2?" | MCP |
| "Review this week's bounce-flagged contacts" | MCP |
| "Delete 4,000 no-email contacts with an audit trail" | Scripts (`/delete-no-email-contacts`) |
| "Create 10 segmentation lists" | Scripts (`/build-smart-lists`) |
| "Export every workflow to JSON" | Scripts (`/workflows-as-code`) |

## Prerequisites

- A HubSpot account and a user with permission to create user-level apps (or approval via your portal's MCP Auth Apps governance, if enabled)
- Claude Code (or another MCP client)
- This is **separate** from the private app token the scripts use — MCP connects via OAuth as *you*, with your HubSpot permissions

## Execution Pattern

### Stage 1: Plan

1. Decide the scopes to grant. Start read-only (CRM object read scopes); add write scopes only if you want Claude making edits through MCP.
2. Check with your admin whether the portal restricts app installs (App Install Governance) — MCP connections may need approval.

### Stage 2: Execute — Connect

**Option A: HubSpot Connector for Claude (no terminal).** In Claude's connector settings, add the HubSpot connector and complete the OAuth flow in the browser. HubSpot's guide: knowledge.hubspot.com > "Set up and use the HubSpot connector for Claude".

**Option B: MCP client configuration.** Add the remote server to Claude Code:

```bash
claude mcp add --transport http hubspot https://mcp.hubspot.com
```

Then authenticate when prompted — the OAuth flow creates a user-level app with the scopes you approve.

**Option C: HubSpot CLI.** With HubSpot CLI v8.2.0+, `hs mcp setup` walks through connecting an MCP client, and also offers HubSpot's *local developer* MCP server (aimed at app development rather than CRM administration).

### Stage 3: After — Verify

Ask Claude to perform a harmless read and confirm it round-trips:

> "Using the HubSpot MCP tools, fetch one contact and tell me its email and lifecycle stage."

If the call fails: check that the OAuth flow completed, the scopes include contact read, and your portal hasn't blocked the connection via app governance.

## Rollback

- Disconnect the MCP server from your client settings (`claude mcp remove hubspot` in Claude Code).
- Revoke the connection in HubSpot: Settings > Integrations > Connected Apps — removing the user-level app invalidates its tokens.

## Technical Gotchas

1. **MCP access is user-scoped.** Claude can do exactly what your HubSpot user can do — no more, no less. Bulk-risky operations are naturally bounded by the scopes you grant; grant write scopes deliberately.
2. **Two credentials, two purposes.** `HUBSPOT_ACCESS_TOKEN` (private app) powers the scripts; the MCP OAuth connection powers conversational access. Rotating one does not affect the other.
3. **Rate limits still apply.** MCP calls consume the same API capacity as any integration. Don't use MCP for high-volume sweeps — that's script territory.
4. **Headless environments.** MCP servers that require interactive OAuth may be unavailable in scheduled/CI runs. Scripts with a private app token work everywhere; treat MCP as the interactive layer.

---

<!-- skill: hubspot-audit | category: audit-planning | scripts: none -->

---
name: hubspot-audit
description: "Run a comprehensive HubSpot CRM database audit. Analyzes contacts, companies, deals, engagement, data quality, and deliverability. Use when starting a CRM cleanup, onboarding a new client, or performing quarterly health checks."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: audit-planning
---

# HubSpot CRM Database Audit

Run a full diagnostic audit of a HubSpot CRM portal. This skill collects metrics across eight dimensions, grades each one, and produces a prioritized report with actionable recommendations.

## Setup

1. **Get the API token.** Check `.env` for `HUBSPOT_ACCESS_TOKEN`. If it is not set, ask the user to provide their HubSpot private app access token and store it in `.env`:
   ```
   HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
   ```

2. **No package install needed.** Scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves `requests` and `python-dotenv` automatically:
   ```bash
   uv run skills/hubspot-audit/scripts/audit_portal.py
   ```

3. **Create the output directory** if it does not exist:
   ```bash
   mkdir -p reports
   ```

## Audit Dimensions

Run queries for each of the following eight dimensions. Collect exact counts for every metric listed.

### 1. Database Size
- Total contacts
- Total companies
- Total deals
- Marketing contacts vs non-marketing contacts (if Marketing Hub is active)

### 2. Email Deliverability
- Hard bounced contacts (`hs_email_hard_bounce_reason_enum` is not empty)
- Soft bounced contacts (`hs_email_bounce` > 0 AND no hard bounce)
- Global unsubscribes (`hs_is_unworked` or `hs_email_optout` = true)
- Never-emailed contacts (no `hs_email_last_send_date`)
- Invalid email format (regex check on `email` property)
- Contacts with 3+ bounces

### 3. Data Completeness
- Missing `email`
- Missing `company` (contact-level)
- Missing `industry` (contact-level)
- Missing `country` and/or `state`
- Missing `lifecyclestage`
- Missing `hubspot_owner_id`
- Missing `jobtitle`
- Companies missing `domain`
- Companies missing `industry`
- Companies missing `city` / `state` / `country`

### 4. Engagement Health
- Last activity distribution: active in last 30 days, 31-90 days, 91-180 days, 181-365 days, 365+ days, never engaged
- Contacts who opened an email in the last 90 days (`hs_email_last_open_date`)
- Contacts who never opened any email
- Contacts with zero page views
- Contacts with zero form submissions
- Current-customer consistency: contacts where `hs_current_customer` = true but lifecycle stage is not Customer (the `hs_current_customer` system property is read-only, set by HubSpot, and available since June 2026)

Note: aggregate *per-email* open/click rates are not derivable from contact properties — they come from the marketing email statistics API (v3, Marketing Hub plans) or the email health dashboard in the UI. The per-contact last-open/last-click properties above are the right audit signal at the database level.

### 5. Duplicate Analysis
- Duplicate email addresses (exact match)
- Companies sharing the same `domain`
- Companies with very similar names (fuzzy — note: API cannot do fuzzy matching natively; count exact duplicates on `name` and flag for manual review)

### 6. Owner Health
- Deactivated owners who still have assigned contacts
- Deactivated owners who still have assigned companies
- Deactivated owners who still have assigned deals
- Contacts with no owner
- Companies with no owner

### 7. List & Workflow Health
- Total active lists vs static lists
- Lists with zero members
- Workflows currently active
- Workflows that have not enrolled anyone in 90+ days
- Forms with zero submissions
- Forms with submissions in last 30 days

### 8. Deal Pipeline Health
- Deals without `amount`
- Deals without `closedate`
- Deals in each pipeline stage
- Stale deals (no activity in 60+ days, still open)
- Average deal age by stage

## API Technical Notes

These details are critical for getting accurate results:

- **Null checks**: Use the `NOT_HAS_PROPERTY` filter operator to find contacts where a property has never been set. HubSpot stores "never happened" as null (property absent), not as 0 or empty string.
  ```python
  {
      "filterGroups": [{
          "filters": [{
              "propertyName": "hs_email_last_send_date",
              "operator": "NOT_HAS_PROPERTY"
          }]
      }]
  }
  ```

- **Search API pagination limit**: The Search API returns a maximum of 10,000 results per query. If you expect more than 10K, segment queries by another property (e.g., `createdate` ranges, lifecycle stage, or first letter of email) and sum the results.

- **Deactivated owners**: The Owners API does not return deactivated owners by default. Pass `archived=true`:
  ```python
  resp = requests.get(f"{BASE}/crm/v3/owners", headers=HEADERS,
                      params={"limit": 100, "archived": "true"})
  ```

- **Rate limiting**: Private apps are limited to 100 requests per 10 seconds. Add a small delay between batch calls or use exponential backoff on 429 responses.

- **Engagement timestamps**: Use `hs_last_sales_activity_timestamp` and `notes_last_contacted` for activity dating. `hs_email_last_open_date` and `hs_email_last_click_date` are useful for email engagement specifically.

- **Marketing contact status**: The property `hs_marketable_status` indicates whether a contact is set as a marketing contact. This property is **read-only via API**.

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Audit (read-only) | [`scripts/audit_portal.py`](./scripts/audit_portal.py) | `uv run skills/hubspot-audit/scripts/audit_portal.py` |

## Script Structure

This skill ships [`scripts/audit_portal.py`](./scripts/audit_portal.py), which:

1. Loads `HUBSPOT_ACCESS_TOKEN` from `.env`
2. Calls the REST endpoints directly with `requests`:
   ```python
   TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
   BASE = "https://api.hubapi.com"
   HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}
   ```
3. Runs each dimension's queries sequentially (respecting rate limits)
4. Collects all results into a structured dict
5. Computes letter grades per dimension (see grading rubric below)
6. Renders the markdown report
7. Saves to `reports/hubspot-audit-{YYYY-MM-DD}.md`

If a dimension needs portal-specific queries beyond what the script covers, extend it with the same house style rather than writing a separate one-off script.

**MCP note:** if HubSpot's MCP server is connected (see `/connect-hubspot-mcp`), use it for spot-checks while auditing — opening sample records behind a suspicious count is faster than writing one-off queries. The audit's counting queries themselves should stay in the script for reproducibility.

## Grading Rubric

Assign a letter grade to each dimension based on severity:

| Grade | Meaning | Criteria |
|-------|---------|----------|
| A | Healthy | < 5% of records affected |
| B | Minor issues | 5-15% of records affected |
| C | Needs attention | 15-30% of records affected |
| D | Significant problems | 30-50% of records affected |
| F | Critical | > 50% of records affected |

For dimensions without a simple percentage (e.g., Owner Health), use judgment based on the number of affected records and business impact.

## Output Format

Save the report to `reports/hubspot-audit-{YYYY-MM-DD}.md` with this structure:

```markdown
# HubSpot CRM Audit Report

**Date:** YYYY-MM-DD
**Portal ID:** [portal-id]

## Executive Summary

| Dimension | Grade | Key Finding |
|-----------|-------|-------------|
| Database Size | B | ~XX,000 contacts, XX,000 companies |
| Email Deliverability | D | XX% hard bounced, XX% globally unsubscribed |
| Data Completeness | F | XX% missing email, XX% missing industry |
| Engagement Health | D | XX% never engaged, XX% inactive 12+ months |
| Duplicate Analysis | C | ~X,XXX duplicate company domains |
| Owner Health | F | X deactivated owners with XX,XXX assigned contacts |
| List & Workflow Health | B | XX unused lists, X stale workflows |
| Deal Pipeline Health | C | XX% deals missing amount, XX stale deals |

**Overall Grade: X**

## Priority Recommendations

1. **[CRITICAL] Delete contacts with no email** — XX,XXX contacts with no email address
   are unbillable dead weight. Run `/delete-no-email-contacts`.
   *Effort: 1 hour | Fully scriptable*

2. **[CRITICAL] Suppress hard bounced contacts** — XX,XXX hard bounces are destroying
   sender reputation. Run `/suppress-hard-bounced`.
   *Effort: 1 hour | Hybrid (API + workflow)*

3. **[HIGH] Reassign deactivated owner contacts** — XX,XXX contacts assigned to
   X deactivated users. Run `/reassign-deactivated-owners`.
   *Effort: 2 hours | Fully scriptable*

4. ...continue ranked by impact...

---

## Detailed Findings

### 1. Database Size

| Metric | Count | % of Total |
|--------|-------|------------|
| Total Contacts | XX,XXX | — |
| Total Companies | XX,XXX | — |
| Total Deals | X,XXX | — |
| Marketing Contacts | XX,XXX | XX% |

### 2. Email Deliverability

| Metric | Count | % of Contacts |
|--------|-------|---------------|
| Hard Bounced | X,XXX | XX% |
| Soft Bounced | X,XXX | XX% |
| Global Unsubscribes | X,XXX | XX% |
| Never Emailed | XX,XXX | XX% |
| Invalid Email Format | XXX | X% |

...continue for all 8 dimensions...

---

## Next Steps

Run `/hubspot-implementation-plan` to generate a phased cleanup plan based on these findings.
```

## Skill Prescription

After generating the audit report, **prescribe a specific ordered list of skills the user should run**. Do not just present findings — tell the user exactly what to do next.

### Step 1: Map Findings to Skills

For each audit finding that scored C or worse, map it to the appropriate skill. Use this category-ordered lookup:

**Database Hygiene** (run first — billing and deliverability impact):
| Finding | Skill | Priority |
|---------|-------|----------|
| Contacts missing email | `/delete-no-email-contacts` | P0 |
| Hard bounced contacts | `/suppress-hard-bounced` | P0 |
| Global unsubscribes | `/suppress-global-unsubscribes` | P0 |
| Ghost/never-engaged contacts | `/suppress-ghost-contacts` | P1 |
| Duplicate companies | `/merge-duplicate-companies` | P1 |
| Deactivated owners with contacts | `/reassign-deactivated-owners` | P1 |

**Data Enrichment** (run second — data quality):
| Finding | Skill | Priority |
|---------|-------|----------|
| Missing company name | `/enrich-company-name` | P1 |
| Missing industry | `/enrich-industry` | P1 |
| Inconsistent geo data | `/standardize-geo-values` | P2 |
| Missing geo data | `/backfill-geo-data` | P2 |
| Missing/wrong lifecycle stage | `/fix-lifecycle-stages` | P1 |
| Unowned marketing contacts | `/assign-unowned-contacts` | P1 |

**Segmentation & Scoring** (run third — targeting):
| Finding | Skill | Priority |
|---------|-------|----------|
| No ICP classification | `/create-icp-tiers` | P2 |
| No lead scoring | `/build-lead-scoring` | P2 |
| No segment lists | `/build-smart-lists` | P2 |

**Automation Workflows** (run fourth — prevention):
| Finding | Skill | Priority |
|---------|-------|----------|
| No new-contact hygiene | `/new-contact-hygiene-workflow` | P2 |
| High disengagement rate | `/engagement-suppression-workflow` | P2 |
| No lifecycle automation | `/lifecycle-progression-workflow` | P3 |
| No bounce monitoring | `/bounce-monitoring-workflow` | P2 |

**Ongoing Maintenance** (run last — sustainability):
| Finding | Skill | Priority |
|---------|-------|----------|
| Unused lists | `/cleanup-lists` | P3 |
| Unused forms | `/cleanup-forms` | P3 |
| Stale workflows | `/cleanup-workflows` | P3 |
| Dashboard clutter | `/cleanup-dashboards` | P3 |
| Deal pipeline issues | `/cleanup-deals` | P3 |
| Unused properties | `/cleanup-properties` | P3 |

### Step 2: Present the Ordered Prescription

After the audit report, present a **numbered action list** — not just findings. Format like this:

```markdown
## Your Cleanup Prescription

Based on the audit, here are the skills you should run, in order:

### Immediate (this week)
1. `/delete-no-email-contacts` — X,XXX contacts with no email are inflating your bill
2. `/suppress-hard-bounced` — X,XXX hard bounces are hurting deliverability
3. `/suppress-global-unsubscribes` — X,XXX unsubscribes still counting as marketing contacts

### Next (weeks 2-3)
4. `/reassign-deactivated-owners` — X deactivated users still own X,XXX contacts
5. `/enrich-company-name` — XX% of contacts missing company name
6. `/fix-lifecycle-stages` — X,XXX contacts in invalid lifecycle stages
...

### Later (weeks 4-6)
7. `/create-icp-tiers` — No ICP classification exists yet
8. `/build-lead-scoring` — No scoring model in place
...
```

### Step 3: Handle Missing Skills

If the audit reveals a problem that **no existing skill covers**, do the following:

1. **Tell the user clearly**: "This audit found an issue that isn't covered by any existing skill: [description]."
2. **Offer to create it on the spot**: "I can create a new skill for this right now. It would be called `/[suggested-name]` and would handle [brief description]."
3. **Ask about contributing upstream**: "Would you like to contribute this new skill back to the community? If yes, I'll:
   - Create the skill in `skills/[name]/SKILL.md`
   - Fork the repo (if not already forked)
   - Push the new skill to your fork
   - Open a pull request to `tomgranot/hubspot-admin-skills`

   This helps everyone who uses these skills in the future."
4. If the user agrees, create the skill following the standard SKILL.md format, commit it, and open the PR.
5. If the user declines the upstream contribution, still create the skill locally so they can use it.

### Step 4: Suggest Next Step

End with:
```
Ready to start? Run `/hubspot-implementation-plan` to generate a full phased plan,
or jump straight to the first skill: `/delete-no-email-contacts`.
```

## After Running

- Print the file path of the saved report
- Present the ordered skill prescription (Step 2 above)
- Highlight the top 3 most critical findings
- Flag any findings that have no matching skill (Step 3 above)
- Suggest running `/hubspot-implementation-plan` for the full phased plan

---

<!-- skill: hubspot-implementation-plan | category: audit-planning | scripts: none -->

---
name: hubspot-implementation-plan
description: "Generate a phased implementation plan from a HubSpot audit report. Creates prioritized, sequenced cleanup processes with effort estimates, dependencies, and automation feasibility. Use after running /hubspot-audit."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: audit-planning
---

# HubSpot Implementation Plan Generator

Generate a phased, prioritized cleanup and optimization plan from a HubSpot audit report. This skill reads audit findings, sequences them into phases with dependencies, and outputs a detailed implementation roadmap with automation feasibility for each task.

## Prerequisites

- A completed audit report in `reports/` (generated by `/hubspot-audit`)
- If no audit report exists, instruct the user to run `/hubspot-audit` first

## Steps

### 1. Load the Audit Report

Find the most recent file matching `reports/hubspot-audit-*.md`. Read it and extract:
- Letter grades per dimension
- Exact counts for each metric
- The priority recommendations section

If multiple reports exist, use the most recent by date in the filename.

### 2. Generate the Phased Plan

Analyze the audit findings and organize cleanup tasks into five phases. Each phase builds on the previous one. **Only include tasks that are relevant based on the audit findings** — if a dimension scored A, skip or deprioritize its tasks.

#### Phase 1: Immediate Hygiene (Week 1-2)

These tasks have direct billing or deliverability impact. Do them first.

| Task | Trigger (from audit) | Skill | Automation | Est. Time |
|------|----------------------|-------|------------|-----------|
| Delete contacts with no email address | Data Completeness: missing email count > 0 | `/delete-no-email-contacts` | Fully scriptable | 1 hr |
| Suppress hard bounced contacts | Deliverability: hard bounce count > 0 | `/suppress-hard-bounced` | Hybrid (API + workflow) | 1-2 hrs |
| Suppress global unsubscribes | Deliverability: global unsub count > 0 | `/suppress-global-unsubscribes` | Hybrid (API + workflow) | 1-2 hrs |
| Suppress ghost contacts (never engaged) | Engagement: never-engaged count > threshold | `/suppress-ghost-contacts` | Hybrid (API + workflow) | 2-3 hrs |
| Merge duplicate companies | Duplicates: duplicate domain count > 0 | `/merge-duplicate-companies` | Fully scriptable | 2-4 hrs |
| Reassign deactivated owner contacts | Owner Health: deactivated owners with records > 0 | `/reassign-deactivated-owners` | Fully scriptable | 1-2 hrs |

**Key constraint:** `hs_marketable_status` is **read-only via API**. All suppression tasks use a hybrid approach:
1. Script sets a custom flag property (e.g., `suppress_reason`) via API
2. A HubSpot workflow triggers on that flag and sets the contact as non-marketing

This workflow must be built manually in the HubSpot UI before running suppression scripts.

#### Phase 2: Data Enrichment (Week 3-4)

Fill data gaps to enable segmentation and scoring.

| Task | Trigger (from audit) | Skill | Automation | Est. Time |
|------|----------------------|-------|------------|-----------|
| Enrich company name from association | Completeness: missing company name | `/enrich-company-name` | Fully scriptable | 1-2 hrs |
| Enrich contact industry from company | Completeness: missing industry | `/enrich-industry` | Fully scriptable | 1-2 hrs |
| Standardize country/state values | Completeness: inconsistent geo data | `/standardize-geo-values` | Fully scriptable | 2-3 hrs |
| Backfill country/state from IP or company | Completeness: missing geo data | `/backfill-geo-data` | Fully scriptable | 2-3 hrs |
| Fix lifecycle stages | Completeness: missing or incorrect lifecycle stages | `/fix-lifecycle-stages` | Fully scriptable | 2-3 hrs |
| Assign unowned contacts | Owner Health: unowned contacts > 0 | `/assign-unowned-contacts` | Fully scriptable | 1-2 hrs |

**Key constraint:** Lifecycle stage is forward-only in HubSpot. To set a contact to an *earlier* stage, you must first clear the property (set to empty string), then set the desired value. The script must handle this two-step process.

#### Phase 3: Segmentation & Scoring (Week 4-6)

Build the targeting infrastructure.

| Task | Trigger (from audit) | Skill | Automation | Est. Time |
|------|----------------------|-------|------------|-----------|
| Create ICP tier property and workflows | Always (after enrichment) | `/create-icp-tiers` | Hybrid (API + workflow) | 3-4 hrs |
| Build lead scoring model | Always (after enrichment) | `/build-lead-scoring` | Manual UI only | 4-6 hrs |
| Build smart lists for segments | Always (after scoring) | `/build-smart-lists` | Fully scriptable (Lists API) | 2-3 hrs |
| Create segment lists for campaigns | Always (after smart lists) | `/create-segment-lists` | Fully scriptable | 1-2 hrs |

**Key constraint:** Configuring lead scoring rules is still UI-only (the post-2025 scoring tool has no configuration API), but each score generates CRM properties that ARE readable and filterable via API. Build the scoring model manually based on the ICP tier data from the previous step; verify score distributions via the Search API.

#### Phase 4: Automation (Week 6-8)

Set up ongoing automated hygiene.

| Task | Trigger (from audit) | Skill | Automation | Est. Time |
|------|----------------------|-------|------------|-----------|
| New contact hygiene workflow | Always | `/new-contact-hygiene-workflow` | Hybrid (API + UI review) | 1-2 hrs |
| Engagement-based suppression workflow | Engagement grade D or F | `/engagement-suppression-workflow` | Hybrid (API + UI review) | 1-2 hrs |
| Lifecycle stage progression workflow | Always | `/lifecycle-progression-workflow` | Hybrid (API + UI review) | 1-2 hrs |
| Bounce monitoring workflow | Deliverability grade C or worse | `/bounce-monitoring-workflow` | Hybrid (API + UI review) | 1 hr |
| Export workflows as versioned JSON | After any workflow build | `/workflows-as-code` | Fully scriptable | 30 min |

**Key update:** The v4 Automation API is stable and supports creating, updating, and batch-reading workflows for contacts, companies, deals, and tickets, with all action types (the v3 workflows API is now legacy). The workflow skills are **API-first**: each ships before/execute/after scripts that create the workflows via `POST /automation/v4/flows` — always disabled, so a human reviews them in the UI before enabling. Two options per workflow:

1. **v4 Automation API (primary)** -- Run each skill's scripts, then complete the short UI review. Notes: flows touching sensitive-data properties require the corresponding sensitive-data scopes; a few action types (copy-from-associated-object, internal notifications) have undocumented API field shapes and are added in the UI during review.

2. **Manual UI Build (fallback)** -- Follow the step-by-step instructions in each skill. Required on plan tiers where the Automation API returns 403. (HubSpot Breeze AI can also scaffold workflows from prompts, but it creates event-based OR triggers instead of filter-based AND triggers and cannot configure re-enrollment or "is unknown" conditions — verify everything it builds. The Claude Chrome extension can drive the workflow builder UI directly.)

Each workflow skill (`/new-contact-hygiene-workflow`, `/engagement-suppression-workflow`, `/lifecycle-progression-workflow`, `/bounce-monitoring-workflow`) documents both options. The ICP tier classification workflows in `/create-icp-tiers` are still built in the UI due to their multi-group demotion filter logic. After building, run `/workflows-as-code` to export everything to versioned JSON.

#### Phase 5: Ongoing Maintenance (Ongoing)

Recurring tasks to keep the CRM clean.

| Task | Frequency | Skill | Automation | Est. Time |
|------|-----------|-------|------------|-----------|
| Weekly cleanup routine | Weekly | `/weekly-cleanup-routine` | Checklist (manual) | 1 hr/week |
| Clean up unused lists | Monthly | `/cleanup-lists` | Partially scriptable | 1-2 hrs |
| Clean up unused forms | Monthly | `/cleanup-forms` | Partially scriptable | 1-2 hrs |
| Clean up stale workflows | Monthly | `/cleanup-workflows` | Partially scriptable | 1-2 hrs |
| Clean up dashboards | Quarterly | `/cleanup-dashboards` | Manual UI only | 1-2 hrs |
| Clean up stale deals | Quarterly | `/cleanup-deals` | Partially scriptable | 2-3 hrs |
| Clean up unused properties | Quarterly | `/cleanup-properties` | Partially scriptable | 2-3 hrs |
| Review lead owners | Quarterly | `/cleanup-lead-owners` | Partially scriptable | 1-2 hrs |
| Review bounced contacts | Monthly | `/review-bounced-contacts` | Partially scriptable | 1 hr |
| Quarterly full database audit | Quarterly | `/hubspot-audit` | Fully scriptable | 1 hr |

### 3. Build the Dependency Graph

Map dependencies between tasks. A task cannot start until its dependencies are complete:

```
Phase 1 (all tasks independent of each other, can run in parallel)
  |
  v
Phase 2 (depends on Phase 1 completion)
  - enrich-company-name → enrich-industry (industry comes from company)
  - standardize-geo → backfill-geo (standardize first, then fill gaps)
  - fix-lifecycle-stages (independent)
  - assign-unowned-contacts (independent, but after deactivated owner reassignment)
  |
  v
Phase 3 (depends on Phase 2 completion)
  - create-icp-tiers → build-lead-scoring → build-smart-lists → create-segment-lists
  |
  v
Phase 4 (depends on Phase 3 for full effectiveness, but can start after Phase 2)
  - All workflow tasks are independent of each other
  |
  v
Phase 5 (starts after Phase 4, runs indefinitely)
```

### 4. Compute Effort Summary

Sum up estimated hours and present:

| Category | Hours |
|----------|-------|
| Fully scriptable tasks | X hrs |
| Hybrid tasks (API + manual workflow) | X hrs |
| Manual UI tasks | X hrs |
| **Total estimated effort** | **X hrs** |

### 5. Key Technical Constraints Summary

Include this section in every plan:

1. **`hs_marketable_status` is read-only via API** — This is the single biggest blocker. Any task that needs to suppress or unsuppress a contact as a marketing contact cannot do so directly via API. Workaround: set a custom flag property via API, then trigger a HubSpot workflow on that flag to change marketing status.

2. **Workflows are created via the stable v4 Automation API, always disabled** — Scripts create every workflow with `isEnabled: false`; a human reviews it in the UI before enabling. UI-only leftovers: copy-from-associated-object actions and notification recipients (undocumented API field shapes). The v3 workflows API is legacy — do not build new integrations on it.

3. **Lifecycle stage is forward-only** — HubSpot prevents setting a contact to an earlier lifecycle stage. To fix this, clear the property first (set to empty string via API), then set the desired stage in a second API call.

4. **Search API caps at 10,000 results** — Any query that might return more than 10K results must be segmented (e.g., by date range or property value) and summed. This affects most counting queries on large portals.

5. **Rate limit: 100 requests per 10 seconds** — All scripts must implement rate limiting. Use exponential backoff on HTTP 429 responses.

## Output Format

Save the plan to `reports/implementation-plan-{YYYY-MM-DD}.md` with this structure:

```markdown
# HubSpot CRM Implementation Plan

**Generated:** YYYY-MM-DD
**Based on audit:** hubspot-audit-YYYY-MM-DD.md

## Executive Summary

Based on the audit findings, this portal requires attention across X dimensions.
The most critical issues are:
1. [Top finding from audit]
2. [Second finding]
3. [Third finding]

**Total estimated effort:** XX-XX hours over 6-8 weeks

## Phase 1: Immediate Hygiene (Week 1-2)

**Goal:** Reduce billing costs and protect sender reputation.

### 1.1 Delete Contacts With No Email Address
- **Why:** Contacts without email cannot receive marketing — they are dead weight
  that inflates your contact tier billing.
- **Count from audit:** X,XXX contacts
- **Automation:** Fully scriptable
- **Skill:** `/delete-no-email-contacts`
- **Dependencies:** None
- **Estimated time:** 1 hour
- **Status:** [ ] Not started

### 1.2 Suppress Hard Bounced Contacts
- **Why:** Hard bounces damage sender reputation and deliverability scores.
  These contacts will never receive email again.
- **Count from audit:** X,XXX contacts
- **Automation:** Hybrid — API sets flag, workflow changes marketing status
- **Skill:** `/suppress-hard-bounced`
- **Dependencies:** Suppression workflow must be built in UI first
- **Estimated time:** 1-2 hours
- **Status:** [ ] Not started

### 1.3 Suppress Global Unsubscribes
- **Why:** Globally unsubscribed contacts count toward billing but cannot
  be emailed. Set as non-marketing to reduce costs.
- **Count from audit:** X,XXX contacts
- **Automation:** Hybrid — API sets flag, workflow changes marketing status
- **Skill:** `/suppress-global-unsubscribes`
- **Dependencies:** Suppression workflow must be built in UI first
- **Estimated time:** 1-2 hours
- **Status:** [ ] Not started

...continue for all tasks in all phases...

---

## Dependency Map

```text
[Phase 1] ──→ [Phase 2] ──→ [Phase 3] ──→ [Phase 4] ──→ [Phase 5]
                  │                │
                  ├─ company name  ├─ ICP tiers
                  │   └─→ industry │   └─→ lead scoring
                  ├─ geo std       │       └─→ smart lists
                  │   └─→ geo fill │
                  ├─ lifecycle     │
                  └─ assign owners │
```

---

## Technical Constraints

1. `hs_marketable_status` is read-only via API ...
2. Workflows created via v4 API, always disabled for UI review ...
3. Lifecycle stage is forward-only ...
4. Search API caps at 10K ...
5. Rate limit: 100 req / 10 sec ...

---

## Effort Summary

| Category | Tasks | Hours |
|----------|-------|-------|
| Fully scriptable | X | XX hrs |
| Hybrid (API + workflow) | X | XX hrs |
| Manual UI only | X | XX hrs |
| Ongoing maintenance | X | XX hrs/month |
| **Total initial setup** | **X** | **XX hrs** |

---

## Tracking Checklist

### Phase 1: Immediate Hygiene
- [ ] Delete no-email contacts (X,XXX)
- [ ] Suppress hard bounced (X,XXX)
- [ ] Suppress global unsubscribes (X,XXX)
- [ ] Suppress ghost contacts (X,XXX)
- [ ] Merge duplicate companies (X,XXX)
- [ ] Reassign deactivated owner contacts (X,XXX)

### Phase 2: Data Enrichment
- [ ] Enrich company name from association
- [ ] Enrich industry from company
- [ ] Standardize country/state values
- [ ] Backfill country/state data
- [ ] Fix lifecycle stages
- [ ] Assign unowned contacts

### Phase 3: Segmentation & Scoring
- [ ] Create ICP tier property + workflows
- [ ] Build lead scoring model
- [ ] Build smart lists
- [ ] Create segment lists

### Phase 4: Automation
- [ ] New contact hygiene workflow
- [ ] Engagement suppression workflow
- [ ] Lifecycle progression workflow
- [ ] Bounce monitoring workflow

### Phase 5: Ongoing Maintenance
- [ ] Weekly cleanup routine established
- [ ] Monthly list/form/workflow cleanup scheduled
- [ ] Quarterly audit scheduled
```

### 6. Handle Gaps — Missing Skills

After mapping all audit findings to skills, check if any findings have **no matching skill**. This is expected — every HubSpot portal is different and some issues are unique.

For each unmapped finding:

1. **Flag it in the plan**: Add a section called "Custom Skills Needed" listing each gap.

2. **Offer to create the skill**:
   ```
   The audit found [X] issues that aren't covered by existing skills:

   1. [Issue description] — suggested skill name: `/[name]`
   2. [Issue description] — suggested skill name: `/[name]`

   I can create these skills for you right now. Want me to proceed?
   ```

3. **Ask about upstream contribution**:
   ```
   These new skills could help other HubSpot admins facing the same issues.
   Would you like to contribute them back to the community?

   If yes, I'll:
   - Create each skill following the standard SKILL.md format
   - Push them to your fork of hubspot-admin-skills
   - Open a pull request to the upstream repo

   This is completely optional — you can also keep them local.
   ```

4. If the user agrees, for each new skill:
   - Create `skills/<name>/SKILL.md` with proper frontmatter and the 4-stage pattern
   - Add it to the plan as a regular task
   - After all skills are created, commit and push to the user's fork
   - Open a PR to `tomgranot/hubspot-admin-skills` with a description of what each skill does

### 7. Contribution Quick-Start

If the user wants to help improve the skill set (whether they found gaps or not), provide these instructions:

```markdown
## Want to contribute?

1. **Fork**: `gh repo fork tomgranot/hubspot-admin-skills --clone`
2. **Branch**: `git checkout -b skill/your-skill-name`
3. **Create**: Add `skills/<your-skill>/SKILL.md` following the standard format:
   - YAML frontmatter (name, description, license, metadata)
   - 4-stage pattern: Plan → Before State → Execute → After State
   - API code examples where applicable
   - Safety mechanisms and rollback instructions
4. **Test**: Run the skill against a HubSpot sandbox portal
5. **PR**: `gh pr create --repo tomgranot/hubspot-admin-skills`

Your contribution helps every HubSpot admin who uses these skills.
```

## After Running

- Print the file path of the saved plan
- Highlight the Phase 1 tasks as immediate priorities
- Remind the user to build the suppression workflow in HubSpot UI before running hybrid tasks
- Suggest starting with `/delete-no-email-contacts` as the first concrete action
- Flag any findings that need custom skills (Step 6) and offer to create them
- Provide the contribution quick-start if the user shows interest in contributing

---

<!-- skill: sandbox-self-test | category: audit-planning | scripts: none -->

---
name: sandbox-self-test
description: "Verify the entire toolkit against a disposable HubSpot developer test account or sandbox that you bring: seed synthetic fixtures, run every scripted skill's read path, exercise end-to-end and API round-trip cases, produce a graded report, and tear everything down. Hard-refuses to run against production."
license: MIT
metadata:
  author: tomgranot
  version: "1.0"
  category: audit-planning
---

# Sandbox Self-Test

## Purpose

Every skill in this repo mutates or reads a live HubSpot portal, and no two portals are alike. This skill lets anyone — a contributor before opening a PR, an admin before trusting the toolkit with production, or the maintainer after a HubSpot API change — verify the whole toolkit against a **disposable portal they bring themselves**. HubSpot gives every account up to 10 free developer test accounts, so there is no shared test infrastructure to maintain and no credentials in the repo: bring your own sandbox, run the suite, read the report, throw the sandbox away.

## Key Constraint

**This suite seeds, mutates, and deletes data. It must never touch production.** The lockout is enforced in code, not by convention:

1. The suite reads its own env var, `HUBSPOT_SANDBOX_ACCESS_TOKEN` — it never reads `HUBSPOT_ACCESS_TOKEN`, so a production token in your `.env` cannot be picked up by accident.
2. Every script (not just preflight) calls `GET /account-info/v3/details` before acting and **refuses unless `accountType` is `DEVELOPER_TEST` or `SANDBOX`**. The check fails closed — any error verifying the account type is a refusal — and there is no override flag.

## Prerequisites

- A **developer test account** (free: HubSpot Settings > Testing > Developer test accounts, up to 10 per account, 90-day expiry renewed by API activity) or a **standard sandbox** (Enterprise plans)
- A private app **inside that test account** with scopes: `crm.objects.contacts.read/write`, `crm.objects.companies.read/write`, `crm.objects.owners.read`, `crm.lists.read/write`, `automation`
- Its token in `.env` as `HUBSPOT_SANDBOX_ACCESS_TOKEN` (keep it separate from your production `HUBSPOT_ACCESS_TOKEN`)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/preflight.py`](./scripts/preflight.py) | `uv run skills/sandbox-self-test/scripts/preflight.py` |
| Execute | [`scripts/seed.py`](./scripts/seed.py) | `uv run skills/sandbox-self-test/scripts/seed.py` |
| Execute | [`scripts/run_suite.py`](./scripts/run_suite.py) | `uv run skills/sandbox-self-test/scripts/run_suite.py` |
| After | [`scripts/teardown.py`](./scripts/teardown.py) | `uv run skills/sandbox-self-test/scripts/teardown.py` |

`preflight.py` gates on account type and probes scopes. `seed.py` creates the fixture matrix (idempotent). `run_suite.py` runs the cases and writes the report (`--list` prints the plan without any API call). `teardown.py` deletes everything marker-matched, with typed confirmation.

## Execution Pattern

### Stage 1: Plan

Confirm with the user:

1. **Which portal.** They must name the developer test account / sandbox being used. If they only have a production token, stop and walk them through creating a test account first — do not improvise.
2. **What is in it.** The suite tolerates other data in the sandbox (all assertions are marker-scoped), but a sandbox that mirrors production data deserves a warning: `delete-no-email-contacts` runs for real and will delete *all* no-email contacts in the portal, not just fixtures. An empty or purpose-made sandbox is the recommendation.
3. **Whether to tear down at the end** (default: yes).

### Stage 2: Before

Run preflight. It must print `GO` — portal ID, account type, and all scope probes OK. A `REFUSED` here is the safety system working; never work around it.

### Stage 3: Execute

Seed, then run the suite:

1. `seed.py` creates 3 companies and 9 contacts, one defect per testable skill (no email, no owner, no lifecycle stage, messy geo spellings, duplicate company domains, missing company name with an association to copy from, an enrichable contact for the mock provider).
2. `run_suite.py` runs three kinds of cases:
   - **before-smoke** — every scripted skill's `before.py` runs as a subprocess with the sandbox token standing in for `HUBSPOT_ACCESS_TOKEN`; read-only, asserts clean exit. This is the widest net: it catches endpoint drift, scope gaps, and pagination breakage across the whole toolkit.
   - **end-to-end** — `delete-no-email-contacts` (full destructive cycle with piped confirmation, verified against the Search API), `waterfall-enrich-contacts` with the **mock provider** (no credits spent, write-back verified), `workflows-as-code` export (JSON on disk verified).
   - **api-roundtrip** — a dynamic list and a v4 workflow are each created (`[SELFTEST]`-prefixed, workflow disabled with a never-matching enrollment filter), fetched, verified, and deleted.

All fixtures are triple-marked: reserved-TLD emails (`@selftest.hubspot-admin-skills.invalid` — can never receive mail), `SELFTEST` name prefixes, `[SELFTEST]` list/workflow prefixes.

### Stage 4: After

Read `reports/selftest-{date}.md` with the user: PASS/FAIL/SKIP per case. Investigate any FAIL before using the failing skill on production — a failure here is either a fixture problem, a scope problem, or a real regression against HubSpot's current API behavior, and telling those apart is the whole point of the suite. Then run teardown.

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **Production lockout** | Account-type gate (`DEVELOPER_TEST`/`SANDBOX` only) enforced independently in preflight, seed, suite, and teardown. Fails closed, no override. |
| **Separate token variable** | `HUBSPOT_SANDBOX_ACCESS_TOKEN` only — a production token in `HUBSPOT_ACCESS_TOKEN` is invisible to this suite. |
| **Marker-scoped teardown** | Deletion matches selftest markers (reserved-TLD email domain, `SELFTEST`/`[SELFTEST]` prefixes) — never "everything in the portal". |
| **CSV audit trail** | Teardown exports every object it will delete to `data/audit-logs/` before deleting, and requires typed `TEARDOWN` confirmation. |
| **No credits spent** | The enrichment case forces `ENRICHMENT_PROVIDER=mock` (deterministic fake data, no network). |
| **Skips are visible** | Anything a sandbox can't simulate is reported `SKIP` with the reason — the report never overstates coverage. |

## Technical Gotchas

1. **A sandbox cannot simulate everything.** Bounce state (`hs_email_hard_bounce_reason_enum`), global unsubscribes (`hs_email_optout`), marketing status (`hs_marketable_status`, read-only via API), and deactivated owners all come from real events HubSpot records — they cannot be fabricated. The suite reports these as SKIP; verify those paths once, manually, when first running the corresponding skills on a real portal.
2. **Search index lag.** The Search API indexes writes asynchronously; the suite sleeps ~5s before asserting post-mutation counts. On a slow day a fresh mutation can still be missed — re-run the suite before concluding a skill is broken.
3. **Developer test accounts expire after 90 days** without API activity. Running this suite counts as activity, so a monthly run keeps the sandbox alive. Deleted fixtures follow the same 90-day recovery window as production.
4. **`before-smoke` failures on an empty sandbox are usually legitimate zero-data exits.** The shipped `before.py` scripts exit 0 on "nothing found" — if one exits non-zero on your sandbox, read its output in the report; that's a real signal, not noise.
5. **Plan-tier gaps.** Developer test accounts carry Enterprise-trial features, but a standard sandbox inherits its parent portal's tier — the v4 workflow round-trip reports SKIP on 403 rather than failing.

## Rollback

Teardown *is* the rollback: `uv run skills/sandbox-self-test/scripts/teardown.py` removes every marker-matched object, and archived records remain recoverable in the sandbox for 90 days (Settings > Data Management > Deleted Objects). The nuclear option is equally valid: delete the developer test account and create a fresh one — that is what "disposable" means. Nothing this skill does can touch production, by construction.

## CI (optional)

A ready-made GitHub Actions workflow ships at [`ci/sandbox-self-test.yml`](./ci/sandbox-self-test.yml). To enable it, copy it into your fork's workflows directory and set the repository secret:

```bash
mkdir -p .github/workflows
cp skills/sandbox-self-test/ci/sandbox-self-test.yml .github/workflows/
gh secret set HUBSPOT_SANDBOX_ACCESS_TOKEN
```

It runs preflight → seed → suite → teardown on **manual dispatch only** and uploads the report as an artifact. It never runs on push or PR — the sandbox is yours, so the trigger is too. (It ships outside `.github/workflows/` deliberately: enabling CI that spends your sandbox is an explicit opt-in, and tokens without the `workflow` scope can still push this repo.)

---

<!-- skill: delete-no-email-contacts | category: database-hygiene | scripts: before,execute,after -->

---
name: delete-no-email-contacts
description: "Delete contacts with no email address from a HubSpot CRM instance. These contacts cannot receive any communication and inflate billing. Fully automated via the HubSpot CRM Search and Batch Archive APIs."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Delete Contacts With No Email Address

## Purpose

Contacts without an email address serve no functional purpose in a HubSpot Marketing Hub instance. They cannot receive marketing emails, sales sequences, or transactional messages. They inflate the billed contact count. This skill identifies and deletes them via the API.

## Prerequisites

- A HubSpot private app access token with `crm.objects.contacts.read` and `crm.objects.contacts.write` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/delete-no-email-contacts/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/delete-no-email-contacts/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/delete-no-email-contacts/scripts/after.py` |

`before.py` counts contacts with no email and exports the CSV baseline. `execute.py` collects IDs, exports the audit CSV, and batch-archives (typed confirmation + safety threshold). `after.py` verifies zero remain.

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm these items with the user:

1. **Root cause**: Ask whether any integrations (CRM sync, form tool, import process) are intentionally creating contacts without email. If so, fix the inflow first.
2. **Threshold**: The default safety abort threshold is 500 contacts. If the user expects more, adjust the threshold in the execute script.
3. **Recovery window**: Confirm the user understands that deleted contacts are recoverable for 90 days via HubSpot Settings > Data Management > Deleted Objects.

### Stage 2: Before

Run a count query to establish the baseline. Save results for comparison.

```python
"""
Before State: Count contacts with no email address.
"""
import os
import json
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

search_payload = {
    "filterGroups": [
        {
            "filters": [
                {
                    "propertyName": "email",
                    "operator": "NOT_HAS_PROPERTY",
                }
            ]
        }
    ],
    "properties": ["firstname", "lastname", "createdate", "hs_object_id"],
    "limit": 1,  # Only need the total count
}

url = f"{BASE}/crm/v3/objects/contacts/search"
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()

data = response.json()
total = data.get("total", 0)

print(f"BEFORE STATE: {total} contacts exist with no email address.")

if total > 0 and data.get("results"):
    sample = data["results"][0]
    props = sample.get("properties", {})
    print(f"  Sample: ID {sample['id']}, "
          f"{props.get('firstname', '(empty)')} {props.get('lastname', '(empty)')}, "
          f"created {props.get('createdate', '(unknown)')}")
```

**Expected output**: A count of contacts with no email and a sample record for sanity checking.

**Present findings to the user** before proceeding. Ask for explicit confirmation to continue.

### Stage 3: Execute

Collect all contact IDs via paginated search, export a CSV audit trail, then batch-delete.

```python
"""
Execute: Delete all contacts with no email address.
Steps:
  1. Paginated search to collect all contact IDs
  2. Export CSV audit log before deletion
  3. Batch archive in groups of 100
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# --- Step 1: Collect all contact IDs ---
all_contacts = []
after = None

search_payload = {
    "filterGroups": [
        {
            "filters": [
                {
                    "propertyName": "email",
                    "operator": "NOT_HAS_PROPERTY",
                }
            ]
        }
    ],
    "properties": ["firstname", "lastname", "createdate", "hs_object_id"],
    "limit": 100,
}

while True:
    payload = search_payload.copy()
    if after:
        payload["after"] = after

    resp = requests.post(
        f"{BASE}/crm/v3/objects/contacts/search",
        headers=headers, json=payload,
    )
    resp.raise_for_status()
    data = resp.json()

    for contact in data.get("results", []):
        props = contact.get("properties", {})
        all_contacts.append({
            "id": contact["id"],
            "firstname": props.get("firstname", ""),
            "lastname": props.get("lastname", ""),
            "createdate": props.get("createdate", ""),
        })

    paging = data.get("paging", {})
    after = paging.get("next", {}).get("after")
    if not after:
        break
    time.sleep(0.2)  # Rate limiting

print(f"Total contacts to delete: {len(all_contacts)}")

# --- Step 2: SAFETY CHECK ---
ABORT_THRESHOLD = 500
if len(all_contacts) > ABORT_THRESHOLD:
    print(f"SAFETY ABORT: Found {len(all_contacts)} contacts, "
          f"exceeds threshold of {ABORT_THRESHOLD}.")
    print("Review the data and adjust the threshold if this is expected.")
    exit(1)

# --- Step 3: Export CSV audit trail ---
os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/deleted-no-email-contacts.csv"

with open(csv_path, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=["id", "firstname", "lastname", "createdate"])
    writer.writeheader()
    writer.writerows(all_contacts)

print(f"Audit log saved: {csv_path} ({len(all_contacts)} records)")

# --- Step 4: Batch delete ---
all_ids = [c["id"] for c in all_contacts]
BATCH_SIZE = 100
deleted_count = 0
failed_ids = []

for i in range(0, len(all_ids), BATCH_SIZE):
    batch = all_ids[i : i + BATCH_SIZE]
    delete_payload = {"inputs": [{"id": cid} for cid in batch]}

    resp = requests.post(
        f"{BASE}/crm/v3/objects/contacts/batch/archive",
        headers=headers, json=delete_payload,
    )

    if resp.status_code == 204:
        deleted_count += len(batch)
        print(f"  Batch {i // BATCH_SIZE + 1}: deleted {len(batch)} contacts")
    else:
        failed_ids.extend(batch)
        print(f"  Batch FAILED: {resp.status_code} — {resp.text[:200]}")

    time.sleep(0.5)  # Rate limiting between batches

print(f"\nDeleted: {deleted_count}, Failed: {len(failed_ids)}")
```

**Key API details**:
- `POST /crm/v3/objects/contacts/search` with `NOT_HAS_PROPERTY` filter on `email`
- Paginate with `after` cursor, 100 results per page
- `POST /crm/v3/objects/contacts/batch/archive` accepts up to 100 IDs per call
- Successful archive returns HTTP 204 (no content)

### Stage 4: After

Re-run the before-state query to confirm zero contacts remain.

```python
"""
After State: Verify no contacts with missing email remain.
"""
# (Same search payload as Before State)
response = requests.post(url, headers=headers, json=search_payload)
response.raise_for_status()
total = response.json().get("total", 0)

if total == 0:
    print("SUCCESS: 0 contacts with no email remain.")
else:
    print(f"WARNING: {total} contacts with no email still exist.")
    print("New contacts may have been created since deletion. Investigate.")
```

**Present results to the user.** If new contacts appeared, investigate the source (form submissions, integrations, imports).

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **Abort threshold** | Hard-coded at 500 contacts by default. If the search returns more, the script exits without deleting anything. Adjust only with explicit user confirmation. |
| **CSV audit trail** | Every contact ID, name, and create date is exported to CSV before any deletion occurs. |
| **Confirmation prompt** | Always present the Before State count to the user and wait for explicit confirmation before running Execute. |
| **90-day recovery** | Deleted contacts can be restored via HubSpot Settings > Data Management > Deleted Objects for 90 days. |
| **Archived contacts audit** | After deletion, you can retrieve deleted contacts via the standard contacts endpoint with `archived=true` parameter to verify what was removed. |

## Technical Gotchas

1. **`NOT_HAS_PROPERTY` vs `EQ ""`**: Use `NOT_HAS_PROPERTY` operator, not `EQ` with an empty string. HubSpot treats "property not set" differently from "property set to empty string."

2. **Search API pagination limit**: The HubSpot CRM Search API has a hard cap of 10,000 results per query. For this use case (typically a few hundred contacts), this is not an issue. If you encounter it, use segmented queries (e.g., filter by create date ranges).

3. **Rate limiting**: The search API allows ~4 requests/second for a private app. The batch archive API is more restrictive. Use `time.sleep(0.5)` between batch archive calls.

4. **Batch archive returns 204**: A successful batch archive returns HTTP 204 with an empty body, not 200. Check for `status_code == 204`.

5. **Contacts may reappear**: If an integration or form is creating contacts without email, new ones will appear after deletion. Always investigate the root cause.

## Rollback

- Deleted contacts are recoverable for 90 days via HubSpot Settings > Data Management > Deleted Objects.
- The execute script's CSV audit trail lists every deleted contact ID for selective restoration.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/delete-no-email-contacts/scripts/before.py
```

---

<!-- skill: merge-duplicate-companies | category: database-hygiene | scripts: before -->

---
name: merge-duplicate-companies
description: "Identify duplicate company records by domain and name, export audit CSVs for review, and guide merging. API for discovery, third-party tools or manual UI for merging (HubSpot has no bulk merge API)."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Merge Duplicate Companies

## Purpose

Duplicate company records fragment contacts, deals, and engagement history across multiple records for the same real-world company. This leads to inaccurate reporting, broken associations, sales confusion, and workflow failures. This skill identifies duplicates by domain and by name, exports prioritized audit CSVs, and guides the user through merging.

## Prerequisites

- A HubSpot private app access token with `crm.objects.companies.read` scope
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
- Super Admin permissions for merging in the HubSpot UI

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/merge-duplicate-companies/scripts/before.py` |

Only the detection stage is scripted: `before.py` inventories companies and flags duplicate candidates by domain and normalized name. Merging itself happens one pair at a time in the UI (see Key Constraint).

## Key Constraint

**HubSpot has no bulk merge API.** Merging must happen one pair at a time through the HubSpot UI or via third-party tools. The API is used for discovery, analysis, and audit trail generation.

**HubSpot's built-in Duplicates tool is NOT available on all plan tiers.** Check whether the account has access to Settings > Data Management > Duplicates before relying on it.

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm with the user:

1. **Confirm intentional duplicates**: Ask whether separate records for regional offices of the same company are intentional. If so, exclude those from merging.
2. **Merging is irreversible.** Once two company records are merged, they cannot be un-merged. The surviving record inherits all associations, but property values from the deleted record may be lost if both have the same property filled in.
3. **Prioritization strategy**: Recommend merging Customer-stage companies first, then Opportunity-stage, then everything else.
4. **Time estimate**: This is the most time-consuming process. Budget 2-4 hours for critical duplicates, 8-12 hours total for full cleanup.

### Stage 2: Before

Fetch all companies, identify duplicate groups by domain and name, and export audit CSVs.

```python
"""
Before State: Identify duplicate companies by domain and by name.
Creates CSV audit logs for review before merging.
"""
import os
import csv
import time
import requests
from collections import defaultdict
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# --- Step 1: Fetch all companies ---
print("Fetching all companies...")

all_companies = []
after = None

while True:
    params = {
        "limit": 100,
        "properties": "name,domain,lifecyclestage,num_associated_contacts,"
                       "num_associated_deals,hubspot_owner_id,createdate",
    }
    if after:
        params["after"] = after

    resp = requests.get(
        f"{BASE}/crm/v3/objects/companies",
        headers=headers, params=params,
    )
    if resp.status_code != 200:
        print(f"Stopped at {len(all_companies)} (status {resp.status_code})")
        break

    data = resp.json()
    for company in data.get("results", []):
        props = company.get("properties", {})
        all_companies.append({
            "id": company["id"],
            "name": (props.get("name") or "").strip(),
            "domain": (props.get("domain") or "").strip().lower(),
            "lifecycle_stage": props.get("lifecyclestage", ""),
            "associated_contacts": props.get("num_associated_contacts", "0"),
            "associated_deals": props.get("num_associated_deals", "0"),
            "owner_id": props.get("hubspot_owner_id", ""),
            "createdate": props.get("createdate", ""),
        })

    paging = data.get("paging", {})
    after = paging.get("next", {}).get("after")
    if not after:
        break
    time.sleep(0.05)

print(f"Total companies fetched: {len(all_companies)}")

# --- Step 2: Find duplicates by domain ---
print("\nAnalyzing duplicates by domain...")

domain_groups = defaultdict(list)
for c in all_companies:
    if c["domain"]:
        domain_groups[c["domain"]].append(c)

dup_domain_groups = {d: cs for d, cs in domain_groups.items() if len(cs) > 1}
dup_domain_records = sum(len(cs) for cs in dup_domain_groups.values())

print(f"Unique domains with duplicates: {len(dup_domain_groups)}")
print(f"Total records in duplicate domain groups: {dup_domain_records}")

# Top offenders
sorted_domains = sorted(dup_domain_groups.items(), key=lambda x: len(x[1]), reverse=True)
print("\nTop duplicate domains:")
for domain, companies in sorted_domains[:15]:
    print(f"  {domain}: {len(companies)} records")

# --- Step 3: Find duplicates by name ---
print("\nAnalyzing duplicates by name...")

name_groups = defaultdict(list)
for c in all_companies:
    if c["name"]:
        name_groups[c["name"].lower()].append(c)

dup_name_groups = {n: cs for n, cs in name_groups.items() if len(cs) > 1}
dup_name_records = sum(len(cs) for cs in dup_name_groups.values())

print(f"Unique names with duplicates: {len(dup_name_groups)}")
print(f"Total records in duplicate name groups: {dup_name_records}")

sorted_names = sorted(dup_name_groups.items(), key=lambda x: len(x[1]), reverse=True)
print("\nTop duplicate names:")
for name_lower, companies in sorted_names[:15]:
    print(f"  {companies[0]['name']}: {len(companies)} records")

# --- Step 4: Save CSV audit logs ---
os.makedirs("data/audit-logs", exist_ok=True)

# Domain duplicates CSV
domain_csv = "data/audit-logs/duplicate-companies-by-domain.csv"
with open(domain_csv, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "domain", "duplicate_count", "id", "name", "lifecycle_stage",
        "associated_contacts", "associated_deals", "owner_id", "createdate",
    ])
    writer.writeheader()
    for domain, companies in sorted_domains:
        for c in companies:
            writer.writerow({
                "domain": domain,
                "duplicate_count": len(companies),
                **{k: c[k] for k in [
                    "id", "name", "lifecycle_stage", "associated_contacts",
                    "associated_deals", "owner_id", "createdate",
                ]},
            })

print(f"\nDomain duplicates CSV: {domain_csv}")

# Name duplicates CSV
name_csv = "data/audit-logs/duplicate-companies-by-name.csv"
with open(name_csv, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "duplicate_name", "duplicate_count", "id", "name", "domain",
        "lifecycle_stage", "associated_contacts", "associated_deals",
        "owner_id", "createdate",
    ])
    writer.writeheader()
    for name_lower, companies in sorted_names:
        for c in companies:
            writer.writerow({
                "duplicate_name": name_lower,
                "duplicate_count": len(companies),
                **{k: c[k] for k in [
                    "id", "name", "domain", "lifecycle_stage",
                    "associated_contacts", "associated_deals",
                    "owner_id", "createdate",
                ]},
            })

print(f"Name duplicates CSV: {name_csv}")
```

**Present findings to the user.** Key data points:
- Total duplicate domain groups and affected records
- Total duplicate name groups and affected records
- Top offenders by domain and name
- CSVs for manual review

### Stage 3: Execute

This stage is primarily manual. Guide the user through the merging process.

**Option A: HubSpot Built-In Duplicates Tool (if available)**

1. Navigate to **Settings > Data Management > Duplicates > Companies**
2. HubSpot shows suggested duplicate pairs ranked by confidence
3. For each pair, click **Review** to see side-by-side comparison
4. Select the "primary" (surviving) record based on:
   - More associated contacts
   - More associated deals
   - More recent activity
   - Has a company owner
   - More complete property data
5. Click **Merge**
6. Process ~50 pairs at a time; HubSpot loads the next batch automatically

**Prioritization order:**
1. Customer-stage company duplicates (highest value data)
2. Opportunity-stage company duplicates
3. Everything else (Leads, Subscribers)

**Option B: Manual search-and-merge for top offenders**

For companies with many duplicates (4+ records):

1. Search for the company by name in **Contacts > Companies**
2. Identify the "winner" record (most associations, deals, activity)
3. Open the winner record > **Actions** > **Merge**
4. Search for the duplicate > select it > choose property values > **Merge**
5. Repeat until only one record remains

**Option C: Third-party deduplication tools**

For large-scale merging, recommend:
- **Dedupely** (dedupely.com) -- HubSpot-native integration, bulk merge
- **Insycle** (insycle.com) -- Data management platform with dedup
- **Koalify** (koalify.com) -- HubSpot duplicate management

These tools can automate bulk merges that would take hours manually.

**Prevention: Configure auto-association after merging**

```
Settings > Data Management > Companies (or Settings > Objects > Companies)
Enable: "Create and associate companies with contacts"
Set unique identifier: Company domain name
```

This prevents future duplicates by using domain-based matching instead of name-based.

### Stage 4: After

Re-run the Before State analysis and compare duplicate counts.

```python
"""
After State: Verify duplicate reduction.
"""
# Re-fetch all companies and re-run duplicate analysis
# Compare:
#   - Number of duplicate domain groups (should decrease)
#   - Number of duplicate name groups (should decrease)
#   - Top offenders (should be resolved)

# Also verify merged records:
# For each known duplicate that was merged, search for the company
# and confirm only one record exists with all expected associations.
```

**Manual verification:**
1. Search for top offenders by name (should show only 1 record each)
2. Open merged records and verify contacts and deals from both originals appear
3. Check Settings > Data Management > Duplicates -- count should be significantly lower

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **CSV audit trail** | Complete export of all companies with duplicate group annotations before any merging. |
| **Prioritized approach** | Customer and Opportunity companies merged first to protect highest-value data. |
| **Review before merge** | CSVs enable team review before any irreversible merges happen. |
| **Confirmation prompt** | Present duplicate analysis to the user and wait for explicit confirmation before instructing merges. |
| **No auto-merge** | This skill never merges automatically. All merges require manual human decision. |

## Technical Gotchas

1. **HubSpot has no bulk merge API.** There is no programmatic way to merge companies. All merges happen through the UI or third-party tools.

2. **Merging is irreversible.** Once merged, records cannot be split apart. When in doubt, skip a pair and revisit later.

3. **Property conflicts**: When both records have a value for the same property, HubSpot keeps the value from the "primary" record. Review important properties (phone, address, industry) before confirming.

4. **Companies endpoint uses GET, not POST/search.** To list all companies, use `GET /crm/v3/objects/companies` with pagination, not the Search API. The Search API works too but is slower for full exports.

5. **Domain normalization**: Always lowercase and strip whitespace from domains before grouping. `Example.com` and `example.com` are the same company.

6. **Name-based duplicates have higher false-positive rates.** "State University" might match multiple genuinely different institutions. Domain-based duplicates are more reliable.

7. **Contact reassociation**: After merging, verify that contacts from both original records appear under the surviving record. HubSpot should handle this automatically, but spot-check.

8. **The Duplicates tool is plan-tier dependent.** Not all HubSpot plans include it. Check availability before instructing the user to navigate there.

## Rollback

- **Company merges cannot be unmerged.** HubSpot has no unmerge for companies — the only recovery is recreating the secondary company manually from the before CSV and re-associating its contacts and deals.
- This is why the before CSV export and per-pair human review are mandatory.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/merge-duplicate-companies/scripts/before.py
```

---

<!-- skill: reassign-deactivated-owners | category: database-hygiene | scripts: before,execute,after -->

---
name: reassign-deactivated-owners
description: "Reassign contacts and companies from deactivated team members to active owners. Fully automated via the HubSpot Owners API and Batch Update API. Includes territory analysis for informed reassignment decisions."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Reassign Contacts From Deactivated Owners

## Purpose

When team members leave an organization, their HubSpot-owned contacts and companies become orphaned. Leads are not being worked, accounts are not being managed, and any automation that relies on Contact owner or Company owner routes to dead ends. This skill identifies deactivated owners, analyzes their contact portfolios, and batch-reassigns ownership via the API.

## Prerequisites

- A HubSpot private app access token with `crm.objects.contacts.read`, `crm.objects.contacts.write`, `crm.objects.companies.read`, `crm.objects.companies.write`, and `crm.objects.owners.read` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
- A decision from team leadership on the reassignment strategy (see Plan stage)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/reassign-deactivated-owners/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/reassign-deactivated-owners/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/reassign-deactivated-owners/scripts/after.py` |

`before.py` lists deactivated owners and counts their records, exporting the CSV baseline. `execute.py` batch-reassigns to the configured target owner. `after.py` verifies zero records remain with deactivated owners.

## CRITICAL: Retrieving Deactivated Owners

The Owners API requires the `archived=true` parameter to retrieve deactivated users. The default endpoint only returns active owners. This is the most commonly missed detail.

```python
# CORRECT - returns deactivated/archived owners
requests.get(f"{BASE}/crm/v3/owners", headers=headers,
             params={"limit": 500, "archived": "true"})

# DEFAULT - returns only active owners
requests.get(f"{BASE}/crm/v3/owners", headers=headers,
             params={"limit": 500})
```

Deactivated owners have `"archived": true` in their response object.

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: Who should contacts from [deactivated user X] be reassigned to?**
- Run the Before State script first to identify deactivated users and their contact counts, then ask this question for each deactivated user found
- Examples: "Assign Jane Doe's 500 contacts to John Smith", "Distribute evenly across the sales team", "Assign all to the Marketing Team user"
- Default: No default -- this requires an explicit business decision for each deactivated user

**Q2: Should unowned contacts be assigned to a default user? If so, who?**
- Examples: "Yes, assign to our Integration User", "Yes, round-robin across the sales team", "No, leave them unowned for now"
- Default: No -- unowned contacts are a separate strategic decision from deactivated-owner contacts

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm with the user:

1. **Reassignment strategy** -- one of three options:
   - **Option A (Unassign)**: Set owner to blank. Best if no one is actively working these contacts and the team wants to start fresh with new assignment rules.
   - **Option B (Distribute to active team members)**: Round-robin across current reps. Best if there are active reps who should work these leads.
   - **Option C (Assign to shared owner)**: Assign to a single placeholder user (e.g., "Integration User" or "Marketing Team"). Good middle ground.

2. **Territory analysis**: Before reassigning, the user may want to analyze the portfolio of each deactivated owner (customer %, lifecycle distribution, industry breakdown) to make informed decisions about who gets which contacts.

3. **Unassigned contacts**: Ask whether the user also wants to address contacts that have no owner at all. This is a separate strategic decision.

4. **Preserve historical ownership**: Ask whether to create a "Previous Owner" custom property to retain a record of the original assignment.

### Stage 2: Before

Discover all deactivated owners and analyze their contact/company portfolios.

```python
"""
Before State: Identify deactivated owners and their contact/company counts.
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

# --- Step 1: Get all owners (active + deactivated) ---
print("Fetching owners...")

# Active owners
resp_active = requests.get(
    f"{BASE}/crm/v3/owners", headers=headers, params={"limit": 500},
)
resp_active.raise_for_status()
active_owners = resp_active.json().get("results", [])

# CRITICAL: Use archived=true for deactivated owners
resp_archived = requests.get(
    f"{BASE}/crm/v3/owners", headers=headers,
    params={"limit": 500, "archived": "true"},
)
resp_archived.raise_for_status()
deactivated_owners = resp_archived.json().get("results", [])

print(f"Active owners: {len(active_owners)}")
print(f"Deactivated owners: {len(deactivated_owners)}")

print("\nActive owners:")
for o in active_owners:
    print(f"  {o.get('firstName', '')} {o.get('lastName', '')} "
          f"({o.get('email', '')}) -- ID: {o['id']}")

print("\nDeactivated owners:")
for o in deactivated_owners:
    print(f"  {o.get('firstName', '')} {o.get('lastName', '')} "
          f"({o.get('email', '')}) -- ID: {o['id']}")

# --- Step 2: Count contacts per deactivated owner ---
print("\nCounting contacts per deactivated owner...")

url = f"{BASE}/crm/v3/objects/contacts/search"
deactivated_contact_data = {}
total_deactivated_contacts = 0

for o in deactivated_owners:
    owner_id = o["id"]
    name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
    resp = requests.post(url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hubspot_owner_id", "operator": "EQ",
             "value": str(owner_id)},
        ]}],
        "limit": 1,
    })
    if resp.status_code == 200:
        count = resp.json().get("total", 0)
        deactivated_contact_data[name] = {
            "id": owner_id, "email": o.get("email", ""), "contacts": count,
        }
        total_deactivated_contacts += count
        if count > 0:
            print(f"  {name}: {count} contacts")
    time.sleep(0.1)

print(f"\nTotal contacts owned by deactivated users: {total_deactivated_contacts}")

# --- Step 3: Count unassigned contacts ---
print("\nCounting unassigned contacts (no owner)...")

resp_no_owner = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hubspot_owner_id", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
resp_no_owner.raise_for_status()
unassigned_contacts = resp_no_owner.json().get("total", 0)
print(f"Unassigned contacts: {unassigned_contacts}")

# --- Step 4: Count companies per deactivated owner ---
print("\nCounting companies per deactivated owner...")

comp_url = f"{BASE}/crm/v3/objects/companies/search"
total_deactivated_companies = 0

for o in deactivated_owners:
    owner_id = o["id"]
    name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
    resp = requests.post(comp_url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hubspot_owner_id", "operator": "EQ",
             "value": str(owner_id)},
        ]}],
        "limit": 1,
    })
    if resp.status_code == 200:
        count = resp.json().get("total", 0)
        total_deactivated_companies += count
        if count > 0:
            print(f"  {name}: {count} companies")
    time.sleep(0.1)

print(f"\nTotal companies owned by deactivated users: {total_deactivated_companies}")

# --- Step 5: Save CSV audit log ---
os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/ownership-summary.csv"

with open(csv_path, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "owner_name", "owner_id", "email", "status", "contacts", "companies",
    ])
    writer.writeheader()
    for o in deactivated_owners:
        name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
        data = deactivated_contact_data.get(name, {})
        writer.writerow({
            "owner_name": name,
            "owner_id": o["id"],
            "email": o.get("email", ""),
            "status": "deactivated",
            "contacts": data.get("contacts", 0),
            "companies": 0,  # Would need per-owner company count
        })
    for o in active_owners:
        name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
        writer.writerow({
            "owner_name": name,
            "owner_id": o["id"],
            "email": o.get("email", ""),
            "status": "active",
            "contacts": "",
            "companies": "",
        })

print(f"\nAudit CSV saved: {csv_path}")
```

**Optional: Territory analysis template**

For informed reassignment decisions, generate a portfolio breakdown for each deactivated owner:

```python
# Territory analysis: lifecycle + industry breakdown per owner
for o in deactivated_owners:
    owner_id = o["id"]
    name = f"{o.get('firstName', '')} {o.get('lastName', '')}"
    print(f"\n--- Portfolio: {name} ---")

    # Lifecycle breakdown
    for stage in ["customer", "opportunity", "salesqualifiedlead",
                   "marketingqualifiedlead", "lead", "subscriber"]:
        resp = requests.post(url, headers=headers, json={
            "filterGroups": [{"filters": [
                {"propertyName": "hubspot_owner_id", "operator": "EQ",
                 "value": str(owner_id)},
                {"propertyName": "lifecyclestage", "operator": "EQ",
                 "value": stage},
            ]}],
            "limit": 1,
        })
        if resp.status_code == 200:
            count = resp.json().get("total", 0)
            if count > 0:
                print(f"  {stage}: {count}")
        time.sleep(0.1)
```

**Present findings to the user.** Ask for explicit reassignment instructions (which deactivated owner's contacts go to which active owner or shared user).

### Stage 3: Execute

Batch-reassign contacts and companies from deactivated owners to the target owner(s).

```python
"""
Execute: Batch-reassign contacts from deactivated owners.
"""
import time
import requests

# Target owner ID (get from user after Before State)
TARGET_OWNER_ID = "REPLACE_WITH_TARGET_OWNER_ID"


def get_contact_ids_for_owner(owner_id, limit=100):
    """
    Get contact IDs for a given owner using pagination.
    HubSpot search API caps at 10K results per query.
    For larger sets, the caller should loop until no more
    contacts remain for this owner.
    """
    contact_ids = []
    after = None
    while True:
        payload = {
            "filterGroups": [{"filters": [
                {"propertyName": "hubspot_owner_id", "operator": "EQ",
                 "value": str(owner_id)},
            ]}],
            "properties": ["email"],
            "limit": limit,
        }
        if after:
            payload["after"] = after

        resp = requests.post(
            f"{BASE}/crm/v3/objects/contacts/search",
            headers=headers, json=payload,
        )
        if resp.status_code == 400:
            # Hit the 10K search limit -- return what we have
            break
        resp.raise_for_status()

        data = resp.json()
        results = data.get("results", [])
        if not results:
            break

        contact_ids.extend(c["id"] for c in results)

        paging = data.get("paging", {})
        if paging.get("next"):
            after = paging["next"]["after"]
            time.sleep(0.2)
        else:
            break

    return contact_ids


def batch_update_owner(contact_ids, new_owner_id):
    """Update contact owner in batches of 100."""
    success = 0
    failed = 0

    for i in range(0, len(contact_ids), 100):
        batch = contact_ids[i : i + 100]
        payload = {
            "inputs": [
                {"id": cid, "properties": {"hubspot_owner_id": new_owner_id}}
                for cid in batch
            ]
        }

        resp = requests.post(
            f"{BASE}/crm/v3/objects/contacts/batch/update",
            headers=headers, json=payload,
        )
        if resp.status_code == 200:
            success += len(batch)
        else:
            failed += len(batch)
            print(f"  Batch failed: {resp.status_code} -- {resp.text[:200]}")

        time.sleep(0.5)

    return success, failed


# --- Main execution loop ---
total_success = 0
total_failed = 0

for o in deactivated_owners:
    owner_id = o["id"]
    name = f"{o.get('firstName', '')} {o.get('lastName', '')}"

    # Count remaining contacts for this owner
    resp = requests.post(url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hubspot_owner_id", "operator": "EQ",
             "value": str(owner_id)},
        ]}],
        "limit": 1,
    })
    count = resp.json().get("total", 0)
    if count == 0:
        continue

    print(f"\nProcessing: {name} ({count} contacts)...")
    owner_success = 0
    owner_failed = 0
    pass_num = 0

    # Loop because search API caps at 10K -- need multiple passes
    # for owners with >10K contacts
    while True:
        pass_num += 1
        contact_ids = get_contact_ids_for_owner(owner_id)
        if not contact_ids:
            break

        print(f"  Pass {pass_num}: fetched {len(contact_ids)} contact IDs")
        s, f = batch_update_owner(contact_ids, TARGET_OWNER_ID)
        owner_success += s
        owner_failed += f
        print(f"  Updated: {s}, Failed: {f}")

        if f > 0:
            break  # Stop if hitting errors
        time.sleep(1)

    total_success += owner_success
    total_failed += owner_failed
    print(f"  Total for {name}: {owner_success} updated, {owner_failed} failed")

print(f"\nTotal reassigned: {total_success}")
print(f"Total failed: {total_failed}")
```

**For companies, use the same pattern** with the companies endpoint:

```python
# Batch update company owner
payload = {
    "inputs": [
        {"id": company_id, "properties": {"hubspot_owner_id": new_owner_id}}
        for company_id in batch
    ]
}
resp = requests.post(
    f"{BASE}/crm/v3/objects/companies/batch/update",
    headers=headers, json=payload,
)
```

### Stage 4: After

Verify that no contacts or companies remain assigned to deactivated owners.

```python
"""
After State: Verify all deactivated owner contacts are reassigned.
"""
for o in deactivated_owners:
    owner_id = o["id"]
    name = f"{o.get('firstName', '')} {o.get('lastName', '')}"

    # Check contacts
    resp = requests.post(url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hubspot_owner_id", "operator": "EQ",
             "value": str(owner_id)},
        ]}],
        "limit": 1,
    })
    remaining = resp.json().get("total", 0)
    if remaining == 0:
        print(f"  {name}: 0 contacts remaining (OK)")
    else:
        print(f"  {name}: {remaining} contacts still assigned (INVESTIGATE)")

    # Check companies
    resp_c = requests.post(comp_url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hubspot_owner_id", "operator": "EQ",
             "value": str(owner_id)},
        ]}],
        "limit": 1,
    })
    remaining_c = resp_c.json().get("total", 0)
    if remaining_c == 0:
        print(f"  {name}: 0 companies remaining (OK)")
    else:
        print(f"  {name}: {remaining_c} companies still assigned (INVESTIGATE)")

    time.sleep(0.1)
```

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **CSV audit trail** | Full ownership summary exported before any changes. |
| **Territory analysis** | Optional portfolio breakdown (lifecycle, industry) for each deactivated owner to inform reassignment decisions. |
| **Human decision required** | The target owner ID must be explicitly provided by the user. The skill never auto-decides where to reassign. |
| **Multi-pass loop** | Handles owners with >10K contacts by looping until all are reassigned (search API caps at 10K per query). |
| **Error handling** | Batch update stops processing an owner if failures occur, preventing cascading errors. |
| **Previous Owner property** | Recommend creating a custom property to preserve historical ownership before reassignment. |

## Technical Gotchas

1. **CRITICAL: `archived=true` for deactivated owners.** The Owners API default endpoint only returns active owners. You must pass `archived=true` as a query parameter to retrieve deactivated/archived users. Without this, you will see zero deactivated owners and incorrectly conclude the data is clean.

2. **Search API caps at 10K results.** For deactivated owners with more than 10,000 contacts, a single search query cannot return all IDs. Use a multi-pass approach: fetch up to 10K IDs, batch-update them, then search again (the updated contacts no longer match the filter, so the next 10K become available).

3. **Batch update endpoint**: `POST /crm/v3/objects/contacts/batch/update` accepts up to 100 inputs per call and returns HTTP 200 on success (unlike batch archive which returns 204).

4. **Owner ID is a string in filters.** When filtering by `hubspot_owner_id`, pass the value as a string even though it looks numeric: `"value": str(owner_id)`.

5. **Deactivated users may not appear in UI filter dropdowns.** Some HubSpot UI versions hide deactivated users from the Contact Owner filter. Use the API approach instead.

6. **Rate limiting**: Use `time.sleep(0.5)` between batch update calls and `time.sleep(0.2)` between search pagination calls. The private app rate limit is approximately 100 requests per 10 seconds.

7. **Handle unassigned contacts separately.** Contacts with no owner at all (`hubspot_owner_id NOT_HAS_PROPERTY`) are a separate population from deactivated-owner contacts. They require a different strategic decision.

8. **Workflow cleanup after reassignment**: Check for any active workflows or sequences that reference deactivated users by name or ID. Update them to reference active users.

## Rollback

- Owner reassignment is fully reversible: the before CSV records each record's original owner; batch-update `hubspot_owner_id` back to restore.
- Individual changes can be reverted from each record's property history.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/reassign-deactivated-owners/scripts/before.py
```

---

<!-- skill: suppress-ghost-contacts | category: database-hygiene | scripts: before,after -->

---
name: suppress-ghost-contacts
description: "Identify and suppress ghost contacts who received marketing emails but never opened any. These contacts destroy sender reputation and deliverability. Hybrid approach: API for discovery, manual UI for suppression."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Suppress Ghost Contacts (Delivered, Never Opened)

## Purpose

Ghost contacts have received marketing emails but have never opened a single one. They are the largest threat to email deliverability because ISPs like Gmail and Microsoft track engagement at the sender level. Consistently sending to people who never open signals that the sender is producing unwanted email, causing inbox placement to deteriorate even for engaged contacts.

## Prerequisites

- A HubSpot private app access token with `crm.objects.contacts.read` and `crm.lists.read`/`crm.lists.write` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
- Super Admin or Marketing Hub Admin permissions for the manual UI suppression step

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/suppress-ghost-contacts/scripts/before.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/suppress-ghost-contacts/scripts/after.py` |

There is no execute script: the marketing-status change itself must happen via a HubSpot workflow or the UI (see Key Constraint and Stage 3).

## Key Constraint

**`hs_marketable_status` is read-only via the API.** Suppression must happen in the HubSpot UI.

## CRITICAL: "Never Opened" Is Null, Not Zero

HubSpot stores "never opened" as a **null/absent property**, not as the number 0. You **MUST** use the `NOT_HAS_PROPERTY` operator to find contacts who have never opened an email. Using `EQ 0` will return zero results because the property is not set at all for these contacts.

```python
# CORRECT - finds contacts who have never opened
{"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"}

# WRONG - returns nothing because the property does not exist on these contacts
{"propertyName": "hs_email_open", "operator": "EQ", "value": "0"}
```

The same applies to `hs_email_bounce` -- "never bounced" is also null.

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm with the user:

1. **Graduated approach**: Recommend suppressing only contacts above your delivery threshold (typically 5-15, adjust based on your email cadence) first. Contacts below that threshold may not have had enough chances to engage.
2. **Overlap with previous processes**: Some ghost contacts may already be non-marketing from hard-bounce or unsubscribe suppression. The Before State will measure this overlap.
3. **Open tracking caveat**: Some email clients block tracking pixels. However, at the scale of thousands of contacts with zero opens across multiple sends, the overwhelming majority are genuinely unengaged.
4. **Apple Mail Privacy Protection**: Introduced in iOS 15 / macOS Monterey, it pre-loads tracking pixels, which can create false-positive opens. Contacts who do NOT show opens despite this feature are almost certainly truly unengaged.

### Stage 2: Before

Discover all ghost contacts, break down by delivery volume, and generate an audit CSV.

```python
"""
Before State: Count and audit ghost contacts.
Definition: emails delivered > 0, emails opened = null, emails bounced = null.
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

url = f"{BASE}/crm/v3/objects/contacts/search"

# Ghost contact filter definition
GHOST_FILTERS = [
    {"propertyName": "hs_email_delivered", "operator": "GT", "value": "0"},
    {"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"},
    {"propertyName": "hs_email_bounce", "operator": "NOT_HAS_PROPERTY"},
]

# --- Step 1: Total ghost contacts ---
resp = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": GHOST_FILTERS}],
    "limit": 1,
})
resp.raise_for_status()
total_ghosts = resp.json().get("total", 0)
print(f"Total ghost contacts: {total_ghosts}")

# --- Step 2: How many are still marketing? ---
resp2 = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": GHOST_FILTERS + [
        {"propertyName": "hs_marketable_status", "operator": "EQ", "value": "true"},
    ]}],
    "limit": 1,
})
resp2.raise_for_status()
still_marketing = resp2.json().get("total", 0)
already_non_marketing = total_ghosts - still_marketing
print(f"Still marketing: {still_marketing}")
print(f"Already non-marketing (from prior processes): {already_non_marketing}")

# --- Step 3: Breakdown by delivery volume ---
print("\nGhost contacts by delivery volume:")
brackets = [
    ("1-10 emails", "0", "10"),
    ("11-25 emails", "10", "25"),
    ("26-50 emails", "25", "50"),
]

for label, gt_val, lte_val in brackets:
    resp_b = requests.post(url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hs_email_delivered", "operator": "GT", "value": gt_val},
            {"propertyName": "hs_email_delivered", "operator": "LTE", "value": lte_val},
            {"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"},
            {"propertyName": "hs_email_bounce", "operator": "NOT_HAS_PROPERTY"},
        ]}],
        "limit": 1,
    })
    if resp_b.status_code == 200:
        print(f"  {label}: {resp_b.json().get('total', 0)}")
    time.sleep(0.1)

# 50+ emails
resp_50 = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_delivered", "operator": "GT", "value": "50"},
        {"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"},
        {"propertyName": "hs_email_bounce", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
if resp_50.status_code == 200:
    print(f"  50+ emails: {resp_50.json().get('total', 0)}")

# Worst offenders count (above your delivery threshold)
WORST_OFFENDER_THRESHOLD = 15  # Adjust based on your email cadence (typically 5-15)
resp_worst = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_delivered", "operator": "GT", "value": str(WORST_OFFENDER_THRESHOLD - 1)},
        {"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"},
        {"propertyName": "hs_email_bounce", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
resp_worst.raise_for_status()
worst_offenders = resp_worst.json().get("total", 0)
print(f"\n{WORST_OFFENDER_THRESHOLD}+ delivered (recommended for immediate suppression): {worst_offenders}")
```

**Step 4: Export full CSV using segmented queries**

The Search API caps at 10K results. For ghost contacts (often >10K), segment by delivery volume brackets to bypass the limit.

```python
# --- Step 4: Full CSV export using segmented queries ---
PROPS = [
    "email", "firstname", "lastname", "hs_email_delivered",
    "hs_email_open", "hs_email_bounce", "hs_marketable_status",
    "lifecyclestage", "createdate",
]

# Each segment must be under 10K for full pagination
SEGMENTS = [
    ("1-5 delivered", "0", "5"),
    ("6-10 delivered", "5", "10"),
    ("11-20 delivered", "10", "20"),
    ("21-35 delivered", "20", "35"),
    ("36-50 delivered", "35", "50"),
    ("51+ delivered", "50", None),
]

all_contacts = []

for label, gt_val, lte_val in SEGMENTS:
    seg_filters = [
        {"propertyName": "hs_email_delivered", "operator": "GT", "value": gt_val},
        {"propertyName": "hs_email_open", "operator": "NOT_HAS_PROPERTY"},
        {"propertyName": "hs_email_bounce", "operator": "NOT_HAS_PROPERTY"},
    ]
    if lte_val:
        seg_filters.append(
            {"propertyName": "hs_email_delivered", "operator": "LTE", "value": lte_val}
        )

    after = None
    seg_count = 0
    while True:
        payload = {
            "filterGroups": [{"filters": seg_filters}],
            "properties": PROPS,
            "limit": 100,
        }
        if after:
            payload["after"] = after

        resp = requests.post(url, headers=headers, json=payload)
        if resp.status_code != 200:
            break

        data = resp.json()
        for contact in data.get("results", []):
            props = contact.get("properties", {})
            all_contacts.append({
                "id": contact["id"],
                "email": props.get("email", ""),
                "firstname": props.get("firstname", ""),
                "lastname": props.get("lastname", ""),
                "emails_delivered": props.get("hs_email_delivered", ""),
                "emails_opened": props.get("hs_email_open", ""),
                "emails_bounced": props.get("hs_email_bounce", ""),
                "marketable_status": props.get("hs_marketable_status", ""),
                "lifecycle_stage": props.get("lifecyclestage", ""),
                "createdate": props.get("createdate", ""),
            })
            seg_count += 1

        paging = data.get("paging", {})
        after = paging.get("next", {}).get("after")
        if not after:
            break
        time.sleep(0.12)

    print(f"  {label}: {seg_count} contacts")

os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/ghost-contacts.csv"

with open(csv_path, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "id", "email", "firstname", "lastname", "emails_delivered",
        "emails_opened", "emails_bounced", "marketable_status",
        "lifecycle_stage", "createdate",
    ])
    writer.writeheader()
    writer.writerows(all_contacts)

print(f"\nAudit CSV saved: {csv_path} ({len(all_contacts)} records)")
```

### Stage 3: Execute

**Step 3a: Create HubSpot active lists via API**

Create two lists: a main suppression list and a worst-offender review list.

```python
"""
Execute (API part): Create HubSpot active lists.
"""

# Main ghost list
list1_payload = {
    "name": "CLEANUP: Ghost Contacts - Never Opened",
    "objectTypeId": "0-1",
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [
            {
                "filterBranchType": "AND",
                "filterBranches": [],
                "filters": [
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_delivered",
                        "operation": {
                            "operationType": "NUMBER",
                            "operator": "IS_GREATER_THAN",
                            "value": 0,
                        },
                    },
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_open",
                        "operation": {
                            "operationType": "ALL_PROPERTY",
                            "operator": "IS_UNKNOWN",
                        },
                    },
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_bounce",
                        "operation": {
                            "operationType": "ALL_PROPERTY",
                            "operator": "IS_UNKNOWN",
                        },
                    },
                ],
            }
        ],
        "filters": [],
    },
}

resp1 = requests.post(f"{BASE}/crm/v3/lists", headers=headers, json=list1_payload)
if resp1.status_code in (200, 201):
    lid1 = resp1.json().get("listId") or resp1.json().get("list", {}).get("listId")
    print(f"Main list created! ID: {lid1}")
elif resp1.status_code == 409:
    print("Main list already exists.")

# Worst-offender sub-list (above your delivery threshold)
list2_payload = {
    "name": "REVIEW: Ghost Contacts - High Delivery No Opens",
    "objectTypeId": "0-1",
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [
            {
                "filterBranchType": "AND",
                "filterBranches": [],
                "filters": [
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_delivered",
                        "operation": {
                            "operationType": "NUMBER",
                            "operator": "IS_GREATER_THAN",
                            "value": 14,  # Adjust to match your delivery threshold minus 1
                        },
                    },
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_open",
                        "operation": {
                            "operationType": "ALL_PROPERTY",
                            "operator": "IS_UNKNOWN",
                        },
                    },
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_bounce",
                        "operation": {
                            "operationType": "ALL_PROPERTY",
                            "operator": "IS_UNKNOWN",
                        },
                    },
                ],
            }
        ],
        "filters": [],
    },
}

resp2 = requests.post(f"{BASE}/crm/v3/lists", headers=headers, json=list2_payload)
if resp2.status_code in (200, 201):
    lid2 = resp2.json().get("listId") or resp2.json().get("list", {}).get("listId")
    print(f"Review list created! ID: {lid2}")
elif resp2.status_code == 409:
    print("Review list already exists.")
```

**Step 3b: Suppress contacts in HubSpot UI**

Instruct the user:

1. Open the list **"CLEANUP: Ghost Contacts - Never Opened"** in HubSpot
2. Click the checkbox in the table header row
3. Click **"Select all N contacts in this list"**
4. Click **More** > **Set marketing contact status**
5. Select **Set as non-marketing contact**
6. Click **Confirm**

**Graduated approach recommendation**: If the user prefers a conservative approach, suppress only the **"REVIEW: Ghost Contacts - High Delivery No Opens"** list first. Monitor contacts below your delivery threshold separately -- they may engage with future emails.

**Step 3c: Keep both lists active permanently**

- The main list captures new ghost contacts over time as emails are sent
- The review list grows as contacts accumulate more delivered emails with no engagement
- Run suppression monthly; review for deletion quarterly

### Stage 4: After

Re-run the Before State queries and compare.

```python
"""
After State: Verify ghost contacts have been suppressed.
"""
# Re-check still-marketing count
resp = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": GHOST_FILTERS + [
        {"propertyName": "hs_marketable_status", "operator": "EQ", "value": "true"},
    ]}],
    "limit": 1,
})
resp.raise_for_status()
remaining = resp.json().get("total", 0)

if remaining == 0:
    print("SUCCESS: All ghost contacts are now non-marketing.")
else:
    print(f"WARNING: {remaining} ghost contacts are still marketing.")
```

**Also check email performance**: After 1-2 email sends post-suppression, open rates should improve noticeably because thousands of guaranteed-zero-open contacts have been removed from the send pool.

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **CSV audit trail** | Full export with delivery counts, lifecycle stage, and marketing status before any action. |
| **Graduated suppression** | Recommend starting with contacts above your delivery threshold (typically 5-15). Monitor those below it separately. |
| **Overlap detection** | Before State measures how many are already non-marketing from prior processes. |
| **Two-tier list system** | Main list for all ghosts, review list for worst offenders. |
| **Non-destructive** | Suppression, not deletion. CRM records are preserved. |
| **Confirmation prompt** | Present all findings to the user before proceeding. |

## Technical Gotchas

1. **CRITICAL: `NOT_HAS_PROPERTY`, not `EQ 0`.** HubSpot stores "never opened" as a null/absent property. Using `EQ 0` returns nothing. This is the most common mistake with this process.

2. **Search API pagination limit is 10K.** Ghost contacts often exceed 10K. Use segmented queries by delivery volume brackets (1-5, 6-10, 11-20, etc.) to export the complete set. Choose segment boundaries so each segment stays under 10K.

3. **`hs_email_delivered`** is the correct property for delivery count. Do not confuse with `hs_email_sent` (sent but not necessarily delivered) or `num_unique_conversion_events`.

4. **`hs_email_open`** counts total opens, not unique opens. But for ghost contacts, both are null because no open ever occurred.

5. **List API filter for "is unknown"** uses `operationType: "ALL_PROPERTY"` with `operator: "IS_UNKNOWN"`. This is different from the Search API's `NOT_HAS_PROPERTY`.

6. **`hs_marketable_status` is read-only via API.** Same constraint as all suppression skills. Manual UI action or workflow-flag workaround required.

7. **Overlap with hard-bounce and unsubscribe processes**: Some ghost contacts may have already been suppressed. The Before State overlap detection prevents double-counting the billing impact.

## Rollback

- Suppression is reversible: restore contacts to marketing status via the UI or a workflow.
- If ghosts were deleted rather than suppressed, deleted contacts are recoverable for 90 days via Settings > Data Management > Deleted Objects.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/suppress-ghost-contacts/scripts/before.py
```

---

<!-- skill: suppress-global-unsubscribes | category: database-hygiene | scripts: before,after -->

---
name: suppress-global-unsubscribes
description: "Identify and suppress globally unsubscribed contacts to ensure legal compliance and reduce billing. Hybrid approach: API for discovery and audit, manual UI for suppression (hs_marketable_status is read-only)."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Suppress Globally Unsubscribed Contacts

## Purpose

Contacts who have globally unsubscribed cannot legally be sent marketing emails under CAN-SPAM, GDPR, or CASL. Despite this, they may still be classified as "marketing contacts" in HubSpot, meaning the organization is paying for contacts it cannot and must not email. This skill identifies all globally unsubscribed contacts, analyzes their lifecycle distribution, and guides suppression.

## Prerequisites

- A HubSpot private app access token with `crm.objects.contacts.read` and `crm.lists.read`/`crm.lists.write` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
- Super Admin or Marketing Hub Admin permissions for the manual UI suppression step

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/suppress-global-unsubscribes/scripts/before.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/suppress-global-unsubscribes/scripts/after.py` |

There is no execute script: the marketing-status change itself must happen via a HubSpot workflow or the UI (see Key Constraint and Stage 3).

## Key Constraint

**`hs_marketable_status` is read-only via the API.** The API handles discovery and audit. Actual suppression must happen in the HubSpot UI.

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm with the user:

1. **Do not re-subscribe anyone.** Even if the unsubscribe pattern looks like a batch import error, re-subscribing contacts without explicit consent violates CAN-SPAM and GDPR.
2. **Billing impact**: Non-marketing status changes take effect at the start of the next billing cycle.
3. **Investigate uniform patterns**: If all subscription types show nearly identical unsubscribe counts, this suggests a batch event (migration, import error, compliance sweep) rather than organic opt-outs. Understanding the origin prevents recurrence.

### Stage 2: Before

Count globally unsubscribed contacts, break down by lifecycle stage, and generate an audit CSV.

```python
"""
Before State: Count globally unsubscribed contacts.
Note: >10K results means the Search API pagination limit may apply.
Uses count-only queries and segmented exports.
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

url = f"{BASE}/crm/v3/objects/contacts/search"

# --- Step 1: Total count ---
resp = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"}
    ]}],
    "limit": 1,
})
resp.raise_for_status()
total_unsubscribed = resp.json().get("total", 0)
print(f"Total globally unsubscribed: {total_unsubscribed}")

# --- Step 2: How many are still marketing? ---
resp2 = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"},
        {"propertyName": "hs_marketable_status", "operator": "EQ", "value": "true"},
    ]}],
    "limit": 1,
})
resp2.raise_for_status()
still_marketing = resp2.json().get("total", 0)
already_non_marketing = total_unsubscribed - still_marketing

print(f"Still marketing (need suppression): {still_marketing}")
print(f"Already non-marketing: {already_non_marketing}")

# --- Step 3: Lifecycle stage breakdown ---
print("\nLifecycle stage breakdown:")
stages = [
    "lead", "subscriber", "marketingqualifiedlead",
    "salesqualifiedlead", "opportunity", "customer",
    "evangelist", "other",
]

for stage in stages:
    resp_s = requests.post(url, headers=headers, json={
        "filterGroups": [{"filters": [
            {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"},
            {"propertyName": "lifecyclestage", "operator": "EQ", "value": stage},
        ]}],
        "limit": 1,
    })
    if resp_s.status_code == 200:
        count = resp_s.json().get("total", 0)
        if count > 0:
            print(f"  {stage}: {count}")
    time.sleep(0.1)

# Check contacts with no lifecycle stage
resp_empty = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"},
        {"propertyName": "lifecyclestage", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
if resp_empty.status_code == 200:
    empty_count = resp_empty.json().get("total", 0)
    if empty_count > 0:
        print(f"  (no lifecycle stage): {empty_count}")
```

**CRITICAL: API pagination limit.** The HubSpot CRM Search API caps at 10,000 results per query. For larger sets, use segmented queries to get complete exports.

```python
# --- Step 4: Export CSV using segmented queries ---
# If total > 10K, segment by lifecycle stage to bypass the pagination cap.

PROPS = [
    "email", "firstname", "lastname", "hs_email_optout",
    "hs_marketable_status", "lifecyclestage", "createdate",
]

all_contacts = []

if total_unsubscribed <= 10000:
    # Simple pagination
    segments = [(None, None)]  # No lifecycle filter needed
else:
    # Segment by lifecycle stage to keep each under 10K
    segments = [
        ("lifecyclestage", "lead"),
        ("lifecyclestage", "subscriber"),
        ("lifecyclestage", "marketingqualifiedlead"),
        ("lifecyclestage", "salesqualifiedlead"),
        ("lifecyclestage", "opportunity"),
        ("lifecyclestage", "customer"),
        ("lifecyclestage", "evangelist"),
        ("lifecyclestage", "other"),
        ("lifecyclestage", None),  # NOT_HAS_PROPERTY
    ]

for seg_prop, seg_value in segments:
    after = None
    seg_filters = [
        {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"},
    ]
    if seg_prop and seg_value:
        seg_filters.append(
            {"propertyName": seg_prop, "operator": "EQ", "value": seg_value}
        )
    elif seg_prop and seg_value is None:
        seg_filters.append(
            {"propertyName": seg_prop, "operator": "NOT_HAS_PROPERTY"}
        )

    while True:
        payload = {
            "filterGroups": [{"filters": seg_filters}],
            "properties": PROPS,
            "limit": 100,
        }
        if after:
            payload["after"] = after

        resp = requests.post(url, headers=headers, json=payload)
        if resp.status_code != 200:
            break

        data = resp.json()
        for contact in data.get("results", []):
            props = contact.get("properties", {})
            all_contacts.append({
                "id": contact["id"],
                "email": props.get("email", ""),
                "firstname": props.get("firstname", ""),
                "lastname": props.get("lastname", ""),
                "unsubscribed": props.get("hs_email_optout", ""),
                "marketable_status": props.get("hs_marketable_status", ""),
                "lifecycle_stage": props.get("lifecyclestage", ""),
                "createdate": props.get("createdate", ""),
            })

        paging = data.get("paging", {})
        after = paging.get("next", {}).get("after")
        if not after:
            break
        time.sleep(0.15)

# Save CSV
os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/globally-unsubscribed-contacts.csv"

with open(csv_path, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "id", "email", "firstname", "lastname",
        "unsubscribed", "marketable_status", "lifecycle_stage", "createdate",
    ])
    writer.writeheader()
    writer.writerows(all_contacts)

print(f"\nAudit CSV saved: {csv_path} ({len(all_contacts)} records)")
if len(all_contacts) < total_unsubscribed:
    print(f"Warning: {total_unsubscribed - len(all_contacts)} contacts "
          f"not captured (pagination limits)")
```

**Present findings to the user** before proceeding. Key data points:
- Total unsubscribed contacts
- How many are still billed as marketing (this is the actionable number)
- Lifecycle breakdown (are any customers unsubscribed? That warrants investigation)

### Stage 3: Execute

**Step 3a: Create a HubSpot active list via API**

```python
"""
Execute (API part): Create a HubSpot active list for unsubscribed contacts.
"""
list_payload = {
    "name": "CLEANUP: Globally Unsubscribed",
    "objectTypeId": "0-1",
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [
            {
                "filterBranchType": "AND",
                "filterBranches": [],
                "filters": [
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_optout",
                        "operation": {
                            "operationType": "ENUMERATION",
                            "operator": "IS_EQUAL_TO",
                            "value": "true",
                        },
                    }
                ],
            }
        ],
        "filters": [],
    },
}

resp = requests.post(
    f"{BASE}/crm/v3/lists", headers=headers, json=list_payload,
)

if resp.status_code in (200, 201):
    list_data = resp.json()
    list_id = list_data.get("listId") or list_data.get("list", {}).get("listId")
    print(f"List created! ID: {list_id}")
elif resp.status_code == 409:
    print("List already exists (409 conflict). Use the existing list.")
else:
    print(f"Failed: {resp.status_code} — {resp.text[:300]}")
```

**Step 3b: Suppress contacts in HubSpot UI**

Instruct the user to perform these steps manually:

1. Open the list **"CLEANUP: Globally Unsubscribed"** in HubSpot
2. Click the **checkbox** in the table header row
3. Click **"Select all N contacts in this list"** in the blue banner
4. Click **More** > **Set marketing contact status**
5. Select **Set as non-marketing contact**
6. Click **Confirm**
7. For large lists (10K+), HubSpot may process this in batches over several minutes

**Step 3c: Keep the list active permanently**

Do NOT delete this list. It is DYNAMIC and will automatically capture future unsubscribes. Recommend running this suppression process monthly or setting up a HubSpot workflow:
- Trigger: `Unsubscribed from all email` is equal to `True`
- Action: Set marketing contact status to non-marketing

### Stage 4: After

Re-run the Before State marketing status query. The `still_marketing` count should be zero.

```python
"""
After State: Verify unsubscribed contacts are suppressed.
"""
resp = requests.post(url, headers=headers, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_email_optout", "operator": "EQ", "value": "true"},
        {"propertyName": "hs_marketable_status", "operator": "EQ", "value": "true"},
    ]}],
    "limit": 1,
})
resp.raise_for_status()
remaining = resp.json().get("total", 0)

if remaining == 0:
    print("SUCCESS: All globally unsubscribed contacts are non-marketing.")
else:
    print(f"WARNING: {remaining} unsubscribed contacts are still marketing.")
```

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **CSV audit trail** | Full export of all unsubscribed contacts before any action. |
| **Lifecycle breakdown** | Surfaces unsubscribed customers for investigation before suppression. |
| **Non-destructive** | Contacts are set to non-marketing, not deleted. CRM data is preserved. |
| **Active list** | DYNAMIC list captures future unsubscribes automatically. |
| **Confirmation prompt** | Always present findings and wait for explicit user confirmation. |

## Technical Gotchas

1. **API pagination limit is 10K contacts.** The HubSpot CRM Search API cannot return more than 10,000 results per query, even with pagination. For datasets larger than 10K, use segmented queries -- add a secondary filter (lifecycle stage, create date range, etc.) to break the set into chunks under 10K each.

2. **`hs_email_optout` is the correct property.** The filter is `hs_email_optout EQ true`. Do not confuse with subscription-type-specific opt-outs (e.g., `hs_email_optout_<ID>`), which are per-subscription.

3. **List API filter syntax differs from Search API.** The Lists API uses `operationType: "ENUMERATION"` with `operator: "IS_EQUAL_TO"`, while the Search API uses `operator: "EQ"`. These are different APIs with different schemas.

4. **Uniform unsubscribe patterns are suspicious.** If all subscription types show nearly identical counts, it indicates a batch event (import, migration, compliance sweep) rather than organic opt-outs. Flag this for the user.

5. **Do not re-subscribe contacts.** Even if a batch unsubscribe was an error, the only legally compliant path is to contact them through a non-email channel and ask them to re-subscribe via an opt-in form.

6. **`hs_marketable_status` is read-only via API.** Same constraint as suppress-hard-bounced. See that skill's gotchas for the custom-property-flag workaround.

## Rollback

- Setting contacts back to marketing status is possible in the UI, but a globally unsubscribed contact must re-opt-in before receiving marketing email — do not circumvent consent.
- The before CSV records every affected contact.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/suppress-global-unsubscribes/scripts/before.py
```

---

<!-- skill: suppress-hard-bounced | category: database-hygiene | scripts: before,after -->

---
name: suppress-hard-bounced
description: "Identify and suppress hard-bounced contacts to protect email sender reputation. Hybrid approach: API for discovery and audit, manual UI for suppression (hs_marketable_status is read-only via API)."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: database-hygiene
---

# Suppress Hard-Bounced Contacts

## Purpose

Hard-bounced contacts have permanently undeliverable email addresses. Every email sent to them fails, wastes send volume, and actively damages sender reputation with ISPs like Gmail, Microsoft, and Yahoo. This skill identifies all hard-bounced contacts, exports an audit trail, creates a HubSpot active list for ongoing monitoring, and guides the user through manual suppression in the UI.

## Prerequisites

- A HubSpot private app access token with `crm.objects.contacts.read` and `crm.lists.read`/`crm.lists.write` scopes
- Python 3.10+ with `uv` for package management
- A `.env` file containing `HUBSPOT_ACCESS_TOKEN`
- Super Admin or Marketing Hub Admin permissions for the manual UI suppression step

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/suppress-hard-bounced/scripts/before.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/suppress-hard-bounced/scripts/after.py` |

There is no execute script: the marketing-status change itself must happen via a HubSpot workflow or the UI (see Key Constraint and Stage 3).

## Key Constraint

**`hs_marketable_status` is read-only via the API.** You cannot set a contact to non-marketing programmatically. The API is used for discovery, analysis, and audit trail generation. The actual suppression must happen in the HubSpot UI.

## Execution Pattern

This skill follows a 4-stage execution pattern: **Plan -> Before -> Execute -> After**.

### Stage 1: Plan

Before writing any code, confirm with the user:

1. **Understand the impact**: Suppressed contacts remain in the CRM but stop counting toward the marketing contact billing tier. They cannot receive marketing emails.
2. **Non-marketing processing timing**: HubSpot processes non-marketing status changes at the start of the next billing cycle. Billing savings are not immediate.
3. **High-bounce contacts**: Contacts with 3+ bounces are the most severe reputation risk. Ask whether the user wants a separate review list for potential deletion.

### Stage 2: Before

Discover all hard-bounced contacts, break down by bounce reason, and generate an audit CSV.

```python
"""
Before State: Count and audit hard-bounced contacts.
Creates:
  1. A HubSpot active list for ongoing monitoring
  2. A local CSV audit log of all affected contacts
"""
import os
import csv
import time
import requests
from dotenv import load_dotenv

load_dotenv()

TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
headers = {
    "Authorization": f"Bearer {TOKEN}",
    "Content-Type": "application/json",
}

url = f"{BASE}/crm/v3/objects/contacts/search"

# --- Step 1: Paginated search for all hard-bounced contacts ---
search_payload = {
    "filterGroups": [
        {
            "filters": [
                {
                    "propertyName": "hs_email_hard_bounce_reason_enum",
                    "operator": "HAS_PROPERTY",
                }
            ]
        }
    ],
    "properties": [
        "email", "firstname", "lastname",
        "hs_email_hard_bounce_reason_enum",
        "hs_email_bounce", "lifecyclestage",
        "hs_marketable_status", "createdate",
    ],
    "limit": 100,
}

all_contacts = []
after = None

while True:
    payload = search_payload.copy()
    if after:
        payload["after"] = after

    resp = requests.post(url, headers=headers, json=payload)
    resp.raise_for_status()
    data = resp.json()

    for contact in data.get("results", []):
        props = contact.get("properties", {})
        all_contacts.append({
            "id": contact["id"],
            "email": props.get("email", ""),
            "firstname": props.get("firstname", ""),
            "lastname": props.get("lastname", ""),
            "hard_bounce_reason": props.get("hs_email_hard_bounce_reason_enum", ""),
            "bounce_count": props.get("hs_email_bounce", ""),
            "lifecycle_stage": props.get("lifecyclestage", ""),
            "marketable_status": props.get("hs_marketable_status", ""),
            "createdate": props.get("createdate", ""),
        })

    paging = data.get("paging", {})
    after = paging.get("next", {}).get("after")
    if not after:
        break
    time.sleep(0.2)

print(f"Total hard-bounced contacts: {len(all_contacts)}")

# --- Step 2: Bounce reason breakdown ---
reasons = {}
for c in all_contacts:
    r = c["hard_bounce_reason"] or "(empty)"
    reasons[r] = reasons.get(r, 0) + 1

print("\nBounce reason breakdown:")
for reason, count in sorted(reasons.items(), key=lambda x: -x[1]):
    pct = (count / len(all_contacts) * 100) if all_contacts else 0
    print(f"  {reason}: {count} ({pct:.1f}%)")

# --- Step 3: Marketing status breakdown ---
already_non_marketing = sum(
    1 for c in all_contacts if c["marketable_status"] == "false"
)
still_marketing = len(all_contacts) - already_non_marketing
print(f"\nAlready non-marketing: {already_non_marketing}")
print(f"Still marketing (need suppression): {still_marketing}")

# --- Step 4: High-bounce contacts (3+) ---
high_bounce = [
    c for c in all_contacts
    if c["bounce_count"] and int(float(c["bounce_count"])) >= 3
]
print(f"Contacts with 3+ bounces (review for deletion): {len(high_bounce)}")

# --- Step 5: Save CSV audit log ---
os.makedirs("data/audit-logs", exist_ok=True)
csv_path = "data/audit-logs/hard-bounced-contacts.csv"

with open(csv_path, "w", newline="") as f:
    writer = csv.DictWriter(f, fieldnames=[
        "id", "email", "firstname", "lastname", "hard_bounce_reason",
        "bounce_count", "lifecycle_stage", "marketable_status", "createdate",
    ])
    writer.writeheader()
    writer.writerows(all_contacts)

print(f"\nAudit log saved: {csv_path} ({len(all_contacts)} records)")
```

**Expected output**: Total count, bounce reason breakdown, marketing status split, and CSV export.

**Bounce reason categories to explain to the user**:
- **OTHER**: Generic bounce, often a server configuration issue
- **UNKNOWN_USER**: The mailbox does not exist (most common hard bounce)
- **SPAM**: The receiving server flagged the message as spam -- investigate what content was sent
- **POLICY**: Receiving server policy rejected delivery
- **MAILBOX_FULL**: Technically a soft bounce that HubSpot escalated to hard after repeated failures

### Stage 3: Execute

This is a **hybrid step** -- the API creates a HubSpot list, but suppression must happen in the UI.

**Step 3a: Create a HubSpot active list via API**

```python
"""
Execute (API part): Create a HubSpot active list for hard-bounced contacts.
"""
list_payload = {
    "name": "CLEANUP: Hard Bounced Contacts",
    "objectTypeId": "0-1",  # contacts
    "processingType": "DYNAMIC",  # active list
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [
            {
                "filterBranchType": "AND",
                "filterBranches": [],
                "filters": [
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_hard_bounce_reason_enum",
                        "operation": {
                            "operationType": "ALL_PROPERTY",
                            "operator": "IS_KNOWN",
                        },
                    }
                ],
            }
        ],
        "filters": [],
    },
}

resp = requests.post(
    f"{BASE}/crm/v3/lists", headers=headers, json=list_payload,
)

if resp.status_code in (200, 201):
    list_data = resp.json()
    list_id = list_data.get("listId") or list_data.get("list", {}).get("listId")
    print(f"List created! ID: {list_id}")
elif resp.status_code == 409:
    print("List already exists (409 conflict). Use the existing list.")
else:
    print(f"Failed to create list: {resp.status_code} — {resp.text[:300]}")
```

**Step 3b: Suppress contacts in HubSpot UI**

Instruct the user to perform these steps manually:

1. Open the list **"CLEANUP: Hard Bounced Contacts"** in HubSpot
2. Click the **checkbox** in the table header row to select all contacts on the page
3. Click the **"Select all N contacts in this list"** link in the blue banner
4. Click **More** > **Set marketing contact status**
5. Select **Set as non-marketing contact**
6. Click **Confirm**

**Step 3c (optional): Create a high-bounce review list**

If the user wants to review contacts with 3+ bounces for potential deletion, create a second list:

```python
# Optional: List for contacts with 3+ bounces
review_list_payload = {
    "name": "REVIEW: 3+ Bounces - Possible Delete",
    "objectTypeId": "0-1",
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [
            {
                "filterBranchType": "AND",
                "filterBranches": [],
                "filters": [
                    {
                        "filterType": "PROPERTY",
                        "property": "hs_email_bounce",
                        "operation": {
                            "operationType": "NUMBER",
                            "operator": "IS_GREATER_THAN",
                            "value": 2,
                        },
                    }
                ],
            }
        ],
        "filters": [],
    },
}
```

### Stage 4: After

Re-run the Before State query. Compare the `still_marketing` count -- it should be zero (or near zero if new bounces occurred between Before and After).

```python
"""
After State: Verify hard-bounced contacts have been suppressed.
"""
# Re-run the same search and check marketable_status
still_marketing_after = sum(
    1 for c in all_contacts_after if c["marketable_status"] != "false"
)

if still_marketing_after == 0:
    print("SUCCESS: All hard-bounced contacts are now non-marketing.")
else:
    print(f"WARNING: {still_marketing_after} hard-bounced contacts "
          f"are still marketing. Re-check the list in the UI.")
```

**Important**: Always re-measure before executing. Counts drift over time as new emails bounce.

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **CSV audit trail** | Every hard-bounced contact is exported with full details before any action. |
| **Active list for monitoring** | The HubSpot list is DYNAMIC, so new hard bounces are automatically captured. Keep it active permanently. |
| **Non-destructive suppression** | Contacts are moved to non-marketing status, not deleted. They remain in the CRM with full history. |
| **Separate review list** | Contacts with 3+ bounces are flagged in a dedicated list for deletion review, not auto-deleted. |
| **Confirmation prompt** | Present Before State findings to the user and wait for explicit confirmation before creating lists or instructing UI actions. |

## Technical Gotchas

1. **Property name is `hs_email_hard_bounce_reason_enum`**, not `hs_email_hard_bounce_reason`. The `_enum` suffix is required in API calls.

2. **`hs_marketable_status` is read-only via API.** This is the single biggest constraint. There is no API endpoint to change a contact's marketing status. The only way is through the HubSpot UI or via a HubSpot workflow triggered by a custom property flag.

3. **Workaround for full automation**: Create a custom contact property (e.g., `suppress_marketing_flag`), set it via API, then build a HubSpot workflow that triggers on that flag to set the contact as non-marketing. This adds complexity but enables end-to-end automation.

4. **Billing cycle timing**: Non-marketing status changes take effect at the start of the next billing cycle. Do not expect immediate billing savings.

5. **Bounce count property**: `hs_email_bounce` stores the count as a string that may contain decimal values (e.g., `"3.0"`). Always cast with `int(float(value))`.

6. **Keep the list active permanently.** New hard bounces will occur over time. The active list captures them automatically. Run this suppression process monthly or set up a workflow.

## Rollback

- Suppressed contacts can be restored to marketing status in the UI (Actions > Set as marketing contact) or by the suppression workflow's inverse criteria.
- The before CSV records every affected contact for verification.
- Note: unbouncing an address is separate — HubSpot Support can unbounce legitimately-bounced addresses only in limited cases.

## Setup

Create a `.env` file in the repo root:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
```

No package install is needed — scripts carry PEP 723 inline metadata and run with [`uv`](https://github.com/astral-sh/uv), which resolves dependencies automatically:

```bash
uv run skills/suppress-hard-bounced/scripts/before.py
```

---

<!-- skill: assign-unowned-contacts | category: data-enrichment | scripts: before,execute,after -->

---
name: assign-unowned-contacts
description: "Assign an owner to marketing contacts that have no owner. Ensures every marketable contact has accountability for follow-up, proper lead routing, and accurate owner-based reporting."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Assign Unowned Marketing Contacts

Assign an owner to all marketing contacts that currently have no owner. Unowned contacts create gaps in reporting, prevent proper lead routing, and mean no one is accountable for follow-up on marketing-generated responses.

## Why This Matters

Marketing contacts without an owner are a blind spot. They receive campaigns but no one sees their responses. They appear in aggregate metrics but not in individual pipeline views. In owner-based dashboards and reports, they simply do not exist. For teams using round-robin or territory-based routing, unowned contacts bypass the entire system.

## Prerequisites

- Phase 1 hygiene and earlier Phase 3 enrichment processes completed
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact read/write and owners read scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Access to Contacts with permission to bulk edit owner assignments
- **Approval from team leads before bulk assignment.** This is a business decision, not just a technical one. Get sign-off on the assignment strategy before proceeding.

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/assign-unowned-contacts/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/assign-unowned-contacts/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/assign-unowned-contacts/scripts/after.py` |

`before.py` counts unowned marketing contacts and lists available owners. `execute.py` batch-assigns them to `HUBSPOT_TARGET_OWNER_ID` (set in `.env`; safety threshold 50,000 — high because owner assignment is fully reversible). `after.py` verifies the count dropped to zero.

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: Who should unowned contacts be assigned to?**
- Examples: "Assign all to our Integration User", "Distribute across the sales team", "Assign to the Marketing Team user", "Assign to specific reps by territory"
- Default: No default -- this is a business decision that requires team lead approval

**Q2: Should we use territory-based routing, round-robin, or a single catch-all owner?**
- Examples: Territory-based (assign by geography or industry), round-robin (distribute evenly across active reps), catch-all (assign all to one user)
- Default: Catch-all to a single integration/service user as a temporary measure, with plans to implement proper routing later

## Plan

1. Identify all marketing contacts with no owner (before state)
2. Decide on the assignment strategy (catch-all user vs. territory rules)
3. Execute the bulk assignment
4. Verify all marketing contacts have owners (after state)

## Before

### Create the Unowned Marketing Contacts List

1. Go to **Contacts > Lists > Create list**
2. Select **Active list**
3. Name: `CLEANUP: Unowned Marketing Contacts`
4. Add filters:
   - Marketing contact status > is any of > Marketing contact
   - AND Contact owner > is unknown
5. Save the list and note the count

### Script Approach

Run `uv run skills/assign-unowned-contacts/scripts/before.py`. The core query:

```python
resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "hs_marketable_status", "operator": "EQ", "value": "true"},
        {"propertyName": "hubspot_owner_id", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
print(f"Unowned marketing contacts: {resp.json()['total']}")
```

## Execute

### Assignment Strategy Decision

Choose one of these approaches (requires team lead approval):

**Option A: Catch-All User (Simplest)**
- Assign all unowned contacts to a single integration/service user
- Pro: Fast, ensures 100% coverage immediately
- Con: One "owner" accumulates a large number of contacts; not meaningful for routing
- Best when: You plan to implement proper routing later and just need coverage now

**Option B: Territory/Region Rules**
- Assign based on contact geography, industry, or company size
- Pro: More meaningful ownership, better for sales follow-up
- Con: Requires a defined routing matrix and more complex execution
- Best when: You have established sales territories

**Option C: Round-Robin**
- Distribute evenly across active sales reps
- Pro: Fair distribution, immediate accountability
- Con: May assign contacts to reps who do not cover that segment
- Best when: Small team, all reps handle all segments

### Bulk Assignment via UI

1. Open the unowned marketing contacts list
2. Click the checkbox in the table header to select all contacts on the page
3. Click **Select all X contacts** to select across all pages
4. Click **Edit** in the toolbar
5. In the property dropdown, select **Contact owner**
6. Search for and select the chosen owner
7. Click **Update**
8. Confirm the bulk edit
9. For large numbers (5,000+), HubSpot processes in batches. This may take several minutes.

### Bulk Assignment via API

For the catch-all strategy, this is fully scripted: set `HUBSPOT_TARGET_OWNER_ID` in `.env` (run `before.py` to see available owner IDs) and run `uv run skills/assign-unowned-contacts/scripts/execute.py`.

```python
# What execute.py does:
# 1. POST /crm/v3/objects/contacts/search — unowned marketing contacts (paginated)
# 2. Build batch payload: {"inputs": [{"id": ..., "properties": {"hubspot_owner_id": OWNER_ID}}]}
# 3. POST /crm/v3/objects/contacts/batch/update in batches of 100

# For territory-based routing (extend the script):
# 1. Search for unowned marketing contacts with country/state/industry properties
# 2. Map each contact to an owner via your territory matrix
# 3. Batch update with the appropriate owner per contact
```

**API notes:**
- Get owner IDs from the Owners API: `GET /crm/v3/owners?limit=100`
- To find a specific owner by email: iterate through owners and match on `email`
- Batch update accepts up to 100 records per call
- Rate limit: 100 requests per 10 seconds

## After

Wait 5-10 minutes for HubSpot to finish processing, then verify: `uv run skills/assign-unowned-contacts/scripts/after.py` (re-runs the before-state query and compares against the baseline; the count should be 0).

**Verification checklist:**

1. The unowned marketing contacts list shows 0 contacts
2. Re-run the before-state script — count should be 0
3. Spot-check 5-10 contacts that were previously unowned — confirm they show the assigned owner
4. Check owner-based dashboards/reports to confirm the previously invisible contacts now appear

## Rollback

- Owner assignment is fully reversible: the execute script's CSV audit trail records every contact it assigned. To undo, batch-update those contact IDs with the previous owner (empty for "no owner").
- Individual assignments can also be reverted from each contact's property history.

## Key Technical Learnings

- **This is a business decision, not just a technical one.** Always get approval from sales/marketing leadership on the assignment strategy before executing. Bulk-assigning contacts to the wrong people creates confusion and erodes trust.
- **A catch-all user is a temporary solution.** If you assign to a single integration user, plan a follow-up process to redistribute contacts to actual sales reps when proper routing is established.
- **Pair with lead owner cleanup.** Once proper routing is in place, revisit contacts under the catch-all user and reassign them to real owners.
- **HubSpot bulk edit limits.** For very large batches (10,000+), the UI bulk edit may time out. Use the API approach instead, which handles pagination and batching gracefully.
- **New contacts need routing too.** After this one-time cleanup, implement a workflow or lead rotation rule to automatically assign owners to new marketing contacts going forward.

---

<!-- skill: enrich-company-name | category: data-enrichment | scripts: before,after -->

---
name: enrich-company-name
description: "Populate missing contact company name fields from associated company records using a HubSpot workflow with optional API backfill. Ensures contacts inherit their company name for segmentation, personalization, and ICP classification."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Enrich Contact Company Name from Associated Company

Populate missing contact-level company name fields by copying the value from the associated company record. Uses a HubSpot workflow for ongoing enrichment and optionally an API backfill script for immediate results.

## Why This Matters

Contacts missing a company name cannot be matched to ICP-classified companies, break email personalization tokens, and are invisible to company-based segmentation. In a typical neglected CRM, 40-60% of contacts may be missing this field even though the vast majority have a company association.

## Prerequisites

- HubSpot Marketing Hub Professional or Sales Hub Professional (for Workflows)
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact read/write scopes (for the scripted stages)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Phase 1 hygiene processes completed (invalid/deleted contacts removed first)
- **HubSpot auto-association enabled:** Settings > Objects > Companies > "Create and associate companies with contacts" toggle must be ON. This lets HubSpot automatically create company records from email domains and associate them.

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/enrich-company-name/scripts/before.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/enrich-company-name/scripts/after.py` |

There is no execute script: the enrichment itself runs as a HubSpot workflow (see Execute below), which handles both the backlog and future contacts.

## Plan

1. Enable auto-association if not already on
2. Audit how many contacts are missing company name (before state)
3. Build a workflow that copies company name from the associated company record
4. Optionally run an API backfill script for immediate results
5. Verify enrichment results (after state)

## Before

Run the before-state audit to capture the baseline: `uv run skills/enrich-company-name/scripts/before.py`

The core query it runs:

```python
resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "company", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
print(f"Contacts missing company name: {resp.json()['total']}")
```

**Manual approach:** Go to Contacts > filter by Company name > is unknown. Record the count.

Save the count. This is your baseline for measuring success.

## Execute

### Method 1: HubSpot Workflow (Recommended — Handles Backlog + Future)

1. Go to **Automation > Workflows > Create workflow**
2. Select **Contact-based > Blank workflow**
3. Name: `AUTO-ENRICH: Copy Company Name from Association`

**Enrollment trigger:**
- Contact property > Company name > **is unknown**

**Re-enrollment:**
- Enable re-enrollment when **associated company** changes. This is the safety net: if an association forms after the workflow already ran, the contact gets re-enrolled.

**Action 1: Delay 10 minutes**
- This delay is critical. When a new contact enters HubSpot, the auto-association engine needs time to parse the email domain, find or create a matching company, and create the association. Without this delay, the workflow checks for an association before one exists.

**Action 2: If/then branch**
- Condition: Associated company > Company name (or Name) > **is known**
- **YES branch:** Add a **Copy property** action:
  - Copy FROM: Company > Name
  - Copy TO: Contact > Company name
- **NO branch:** Leave empty (contact exits). These are typically contacts with personal email addresses (gmail, yahoo, etc.) where no company can be determined.

**Activate:**
- Click Review > Turn on
- When prompted, select **Yes, enroll existing contacts**. This enrolls the entire backlog.

### Method 2: API Backfill Script (Optional — Immediate Results)

Use this if you need the data populated immediately rather than waiting for workflow processing.

```python
# Pattern: fetch contacts missing company name,
# look up their associated company, copy the name.
# 1. POST /crm/v3/objects/contacts/search  (company NOT_HAS_PROPERTY)
# 2. POST /crm/v4/associations/contacts/companies/batch/read  (get associations)
# 3. POST /crm/v3/objects/companies/batch/read  (fetch company names)
# 4. POST /crm/v3/objects/contacts/batch/update  (write the contact's company property)
```

**Key API notes:**
- Use the Search API to find contacts where `company` NOT_HAS_PROPERTY
- Search API caps at 10,000 results. Segment by `createdate` ranges if needed.
- Use Associations API v4 to get contact-to-company associations
- Batch update contacts via `POST /crm/v3/objects/contacts/batch/update` (100 per call)
- Respect rate limits: 100 requests per 10 seconds

### Why Do Both?

- The **workflow** handles both backlog (enrolled on activation) AND future contacts automatically. It is the long-term solution.
- The **API backfill** provides immediate results if you cannot wait for workflow processing (which may take hours for large databases).
- If you only do the workflow, that is perfectly fine. It will process the backlog since existing contacts meeting the trigger criteria get enrolled on activation.

## After

Wait 1-2 hours after activating the workflow (longer for very large databases), then verify: `uv run skills/enrich-company-name/scripts/after.py` (re-runs the before-state query and compares against the baseline).

**Verification checklist:**

1. The "missing company name" count should have dropped dramatically (typically from 40-60% to under 10%)
2. Remaining contacts without company names should primarily be those with personal email addresses (gmail.com, yahoo.com, etc.)
3. Spot-check 10-20 contacts to confirm the company name matches their associated company record
4. Check workflow history for errors:
   - Property type mismatch (copying to wrong field type)
   - Multiple associated companies (HubSpot uses the primary company)
5. Verify the workflow continues processing new contacts by checking for recent enrollments

## Rollback

- Turn off the workflow to stop further enrichment (Automation > Workflows > toggle off).
- The workflow only fills empty fields, so there is nothing to "restore" — but if bad values were copied (e.g., from wrong primary associations), filter contacts by the workflow's enrollment history and clear the `company` property, or restore values from each contact's property history.

## Key Technical Learnings

- **The 10-minute delay is a balance.** Auto-association typically completes in a few minutes, but 10 minutes provides a comfortable buffer. If many contacts go down the NO branch and later get associations, increase to 15-20 minutes.
- **Re-enrollment is the safety net.** Even if the delay is not long enough, re-enrollment on "associated company changes" catches late associations. The delay handles the common case; re-enrollment handles edge cases.
- **Primary company wins.** If a contact is associated with multiple companies, HubSpot copies from the primary associated company. Verify primary associations are correct for key contacts.
- **This workflow does NOT overwrite existing values.** The enrollment trigger requires "Company name is unknown", so contacts with an existing company name are never touched.
- **Property type matters.** Contact "Company name" is a single-line text field by default. If someone changed it to a dropdown, the copy action may fail. Check in Settings > Properties before running.
- **Personal email domains exit on the NO branch.** Contacts with gmail.com, yahoo.com, hotmail.com, outlook.com, etc. will not get enriched. This is expected. They need manual enrichment or an external provider — see `/waterfall-enrich-contacts` for the provider-agnostic path.
- **Company name is a prerequisite for ICP Tier classification.** Run this enrichment before creating ICP Tier workflows.
- **Schedule the "after" verification script.** Workflow processing for large databases takes time. Do not check results immediately — schedule the verification for 2-4 hours after activation.

---

<!-- skill: enrich-industry | category: data-enrichment | scripts: before,after -->

---
name: enrich-industry
description: "Backfill contact-level industry from associated company records using a HubSpot workflow. Enables industry-based segmentation for targeted campaigns aligned with ICP verticals."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Enrich Contact Industry from Associated Company

Copy industry data from company records to their associated contacts. In a typical B2B CRM, company records have industry populated at high rates (80-90%) while contact records have almost none. This workflow bridges that gap automatically.

## Why This Matters

Without industry on contact records, you cannot segment email campaigns by vertical. For B2B companies targeting specific industries, this makes the difference between spray-and-pray email blasts and targeted, relevant messaging. Industry data on contacts also feeds ICP tier classification and lead scoring models.

## Prerequisites

- HubSpot Marketing Hub Professional or Sales Hub Professional (for Workflows)
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) for the scripted stages
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Company name enrichment (enrich-company-name skill) should be completed first, as it may trigger new company associations
- Access to Settings > Properties to verify/create the contact Industry property

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/enrich-industry/scripts/before.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/enrich-industry/scripts/after.py` |

There is no execute script: the enrichment itself runs as a HubSpot workflow (see Execute below).

## Plan

1. Verify the contact Industry property exists and is compatible with the company Industry property
2. Audit how many contacts can be enriched (before state)
3. Build a workflow that copies industry from the associated company
4. Verify enrichment results (after state)

## Before

### Check Property Compatibility

This is the most important pre-step. Contacts may have TWO industry properties: `industry` and `industry_name`. You must verify which one HubSpot uses for lists and reports.

1. Go to **Settings > Properties > Contact properties**
2. Search for "Industry"
3. Note ALL industry-related properties on the contact object
4. Check which property is used in existing lists, reports, and workflows
5. The target property must be compatible with the company Industry property:
   - If both are **dropdown select**: option values must match exactly (same spelling, same case)
   - If the contact property is **single-line text**: it will accept any value (safest option)
   - If unsure, use single-line text to avoid copy failures

**If no contact Industry property exists**, create one:
- Object: Contact
- Group: Contact information
- Label: Industry
- Field type: Dropdown select (copy all values from the company Industry property) OR Single-line text (accepts any value)

### Audit Enrichment Opportunity

Run `uv run skills/enrich-industry/scripts/before.py`. The core query:

```python
resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "industry", "operator": "NOT_HAS_PROPERTY"},
    ]}],
    "limit": 1,
})
print(f"Contacts missing industry: {resp.json()['total']}")
```

Also create a HubSpot list to estimate enrichable contacts:
- Filter 1: Contact Industry > is unknown
- Filter 2: AND Associated company > Industry > is known
- This count tells you how many contacts will actually be enriched

## Execute

### Create the Enrichment Workflow

This workflow is nearly identical to the company name enrichment workflow. If you already built that one, clone it and swap the property references.

1. Go to **Automation > Workflows > Create workflow**
2. Select **Contact-based > Blank workflow**
3. Name: `AUTO-ENRICH: Copy Industry from Company`

**Enrollment trigger:**
- Contact property > Industry > **is unknown**
- AND Associated company > Industry > **is known**

**Re-enrollment:**
- Enable re-enrollment on the same criteria. This ensures contacts that later get associated with a company are also enriched.

**Action: Copy property**
- Copy FROM: Company > Industry
- Copy TO: Contact > Industry

**Activate:**
- Click Review > Turn on
- Select **Yes, enroll existing contacts**

**Note:** Unlike the company name workflow, no delay is needed here. If the contact already has an associated company with industry data (checked by the enrollment trigger), the copy can happen immediately.

## After

Wait 1-2 hours for the workflow to process, then verify: `uv run skills/enrich-industry/scripts/after.py` (re-runs the before-state query and compares against the baseline).

**Verification checklist:**

1. Contact industry count should jump from near-zero to tens of thousands
2. The enrichment list (missing industry + has company association) should be near 0
3. Spot-check 20+ contacts for accuracy:
   - Open the contact record
   - Verify the Industry field shows a value
   - Click the associated company and confirm the industry matches
4. Check that the industry distribution on contacts roughly mirrors the company industry distribution
5. Check workflow history for failures — most common is property value mismatch (company has a value that does not match a dropdown option on the contact)

## Rollback

- Turn off the workflow to stop further enrichment.
- The workflow only fills empty fields. If wrong values were copied (e.g., mismatched dropdown options), filter contacts by the workflow's enrollment history and clear the contact `industry` property, or restore individual values from property history.

## External Providers

This skill only moves industry data the portal already has (company → contact). When the *company* itself has no industry value, that gap needs external data — see `/waterfall-enrich-contacts` for the provider-agnostic enrichment path, or HubSpot's Breeze Intelligence add-on for in-platform enrichment.

## Key Technical Learnings

- **Two industry properties can exist.** Some HubSpot portals have both `industry` and `industry_name` on contacts. Verify which one is authoritative before building the workflow. Writing to the wrong one means your lists and reports will not see the data.
- **Dropdown value matching is case-sensitive and exact.** If the company Industry has "Healthcare" and the contact Industry dropdown has "healthcare" (lowercase), the copy will fail. Ensure values match exactly.
- **Consider consolidating similar industries.** Many CRMs have overlapping values like "Healthcare" and "Hospital & Health Care". For segmentation, consider creating a separate "Industry Group" property that maps similar values into broader categories. This is optional but improves list usability.
- **This does not overwrite existing values.** The enrollment trigger requires "Industry is unknown", so contacts that already have industry data are not affected.
- **If using a text field instead of dropdown:** Enrichment works, but you lose the ability to filter by exact dropdown values in lists. You can convert to a dropdown later but will need to clean up inconsistent text values first.
- **Run this after company name enrichment.** Company name enrichment may trigger new company associations, which increases the number of contacts eligible for industry enrichment.
- **Clone the company name workflow.** The structure is nearly identical. Clone it in HubSpot and swap the property references to save time.

---

<!-- skill: fix-lifecycle-stages | category: data-enrichment | scripts: before,execute,after -->

---
name: fix-lifecycle-stages
description: "Ensure all contacts and companies have appropriate lifecycle stages. Backfills missing stages via API, fixes records stuck at disallowed stages, and creates prevention workflows to stop future gaps."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Fix Lifecycle Stages

Ensure every contact and company has an appropriate lifecycle stage. This includes backfilling missing stages, correcting disallowed stage values, and creating prevention workflows that automatically assign stages to new records.

## Why This Matters

Records without a lifecycle stage are invisible in pipeline reports, excluded from stage-based workflows, and cannot be properly segmented. Even a small percentage of missing lifecycle stages corrupts funnel reporting and makes pipeline analytics unreliable. Lifecycle stage data is also a prerequisite for lead scoring models and lifecycle progression workflows.

## Prerequisites

- Phase 1 hygiene processes completed (invalid/deleted contacts removed first)
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact and company read/write scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Access to Contacts and Companies with bulk edit permissions
- Access to Automation > Workflows
- Understanding of HubSpot's lifecycle stage progression rules (see Critical Concept below)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/fix-lifecycle-stages/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/fix-lifecycle-stages/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/fix-lifecycle-stages/scripts/after.py` |

`before.py` audits missing and per-stage counts for contacts and companies. `execute.py` performs the clear-then-set fixes (review its configuration block for the disallowed-stage mapping before running). `after.py` verifies coverage.

## Critical Concept: Forward-Only Lifecycle Progression

**HubSpot has forward-only lifecycle progression by default.** The built-in order is:

Subscriber > Lead > MQL > SQL > Opportunity > Customer > Evangelist

To move a record from a later stage (e.g., "Other", "Evangelist") to an earlier one (e.g., "Lead"), you must:

1. **FIRST** clear the lifecycle stage (set to blank/empty)
2. **THEN** set the new value

A direct set to an earlier stage will be **silently rejected** — no error, no warning, the value simply does not change. This is the single most common gotcha when fixing lifecycle stages.

```python
url = f"{BASE}/crm/v3/objects/contacts/{contact_id}"

# WRONG — silently fails if current stage is "later" than target
requests.patch(url, headers=HEADERS,
               json={"properties": {"lifecyclestage": "lead"}})

# CORRECT — clear first, then set (two separate calls)
requests.patch(url, headers=HEADERS,
               json={"properties": {"lifecyclestage": ""}})
requests.patch(url, headers=HEADERS,
               json={"properties": {"lifecyclestage": "lead"}})
```

## Plan

1. Audit missing and disallowed lifecycle stages (before state)
2. Define which stages are "disallowed" for your business and map them to correct stages
3. Fix contacts with disallowed stages (clear + re-set)
4. Set missing stages to appropriate defaults based on associated company context
5. Create prevention workflows for contacts and companies
6. Verify 100% coverage (after state)

## Before

### Audit Script

Run `uv run skills/fix-lifecycle-stages/scripts/before.py`. The core queries it runs:

```python
def count(object_type, filters):
    resp = requests.post(f"{BASE}/crm/v3/objects/{object_type}/search",
                         headers=HEADERS,
                         json={"filterGroups": [{"filters": filters}], "limit": 1})
    resp.raise_for_status()
    return resp.json()["total"]

missing = count("contacts", [
    {"propertyName": "lifecyclestage", "operator": "NOT_HAS_PROPERTY"}])
print(f"Contacts missing lifecycle stage: {missing}")

stages = ["subscriber", "lead", "marketingqualifiedlead", "salesqualifiedlead",
          "opportunity", "customer", "evangelist", "other"]
for stage in stages:
    n = count("contacts", [
        {"propertyName": "lifecyclestage", "operator": "EQ", "value": stage}])
    if n:
        print(f"  {stage}: {n}")

print(f"Companies missing lifecycle stage: "
      f"{count('companies', [{'propertyName': 'lifecyclestage', 'operator': 'NOT_HAS_PROPERTY'}])}")
```

### Define Disallowed Stages

Decide which lifecycle stage values should not exist in your database. The table below shows **common examples** -- your disallowed stages and their correct mappings will depend on how your organization uses the CRM. Review your own stage distribution and decide what makes sense for your business:

| Example Disallowed Stage | Common Reason | Example Correct Stage |
|--------------------------|---------------|----------------------|
| (empty/blank) | Invisible to reports | Lead (default) |
| Subscriber | Often misapplied when not used for newsletter-only contacts | Lead |
| Other | Meaningless catch-all | Lead |
| Evangelist | Rarely used correctly in most organizations | Customer (if actual customer) or Lead |

**These are starting-point examples only.** Your mapping will differ based on your sales process, integrations, and how stages are currently used. Define your specific mapping before executing.

## Execute

### Step 1: Fix Contacts at Disallowed Stages

For contacts at "Subscriber", "Other", or "Evangelist" that should be moved to "Lead":

```python
# Pattern: Clear then set (required for backward movement)
DISALLOWED_TO_LEAD = ["subscriber", "other", "evangelist"]

for stage in DISALLOWED_TO_LEAD:
    # Search for contacts at this stage
    # Paginate through all results
    # For each batch:
    #   1. Clear lifecycle stage (set to "")
    #   2. Set lifecycle stage to "lead"
    # Use batch API for efficiency (100 per call)
    pass
```

**Important:** The clear-then-set must happen as two separate API calls. You cannot clear and set in one call.

### Step 2: Set Missing Contact Stages with Context

Do not set all missing contacts to "Lead" blindly. Check their associated company context:

1. **Contacts at Customer companies** -> set to "Customer"
2. **Contacts at Opportunity companies** -> set to "Opportunity"
3. **All remaining contacts** -> set to "Lead"

```python
# Pattern for context-aware assignment:
# 1. Search for contacts with no lifecycle stage
# 2. For each, get their primary associated company
# 3. Check the company's lifecycle stage
# 4. Set the contact's stage to match (or "lead" as default)
```

**Manual approach via lists:**
1. Create a list: Lifecycle stage is unknown AND Associated company lifecycle stage is Customer -> bulk edit to "Customer"
2. Create a list: Lifecycle stage is unknown AND Associated company lifecycle stage is Opportunity -> bulk edit to "Opportunity"
3. Remaining contacts in the "no lifecycle stage" list -> bulk edit to "Lead"

### Step 3: Fix Companies Without Lifecycle Stage

1. Check companies with associated deals:
   - Companies with closed-won deals -> set to "Customer"
   - Companies with open deals -> set to "Opportunity"
2. All remaining companies without a stage -> set to "Lead"

### Step 4: Fix Stuck Records

Some records may fail to update due to the forward-only progression rule. Run a "fix stuck" script:

```python
# Pattern: Find records that should be at a stage but are not
# For each:
#   1. Read current lifecycle stage
#   2. If current stage is "later" than target, clear first
#   3. Set the target stage
```

### Step 5: Create Prevention Workflows

**Contact prevention workflow:**

1. Go to **Automation > Workflows > Create workflow**
2. Select **Contact-based > Blank workflow**
3. Name: `AUTO-FIX: Set Default Lifecycle Stage (Lead)`
4. Enrollment trigger: Contact property > Lifecycle stage > **is unknown**
5. Enable re-enrollment
6. Action: Set contact property > Lifecycle stage > **Lead**
7. Activate and enroll existing contacts

**Company prevention workflow:**

1. Create another workflow: **Company-based > Blank workflow**
2. Name: `AUTO-FIX: Set Default Company Lifecycle Stage (Lead)`
3. Enrollment trigger: Company property > Lifecycle stage > **is unknown**
4. Enable re-enrollment
5. Action: Set company property > Lifecycle stage > **Lead**
6. Activate and enroll existing companies

**Optional: Disallowed stage correction workflows:**

If contacts keep getting set to disallowed stages (e.g., by imports or integrations):

1. Create a workflow: Trigger = Lifecycle stage changed to "Subscriber" (or other disallowed value)
2. Action 1: Clear lifecycle stage (set to blank)
3. Action 2: Set lifecycle stage to "Lead"

This prevents disallowed stages from recurring.

## After

Re-run the audit: `uv run skills/fix-lifecycle-stages/scripts/after.py`. Both missing-stage counts (contacts and companies) should be 0.

**Verification checklist:**

1. 0 contacts with missing lifecycle stage
2. 0 companies with missing lifecycle stage
3. 0 contacts at disallowed stages (Subscriber, Other, Evangelist, or whatever you defined)
4. Spot-check contacts from Customer sub-list -> their lifecycle stage is "Customer"
5. Spot-check contacts from Opportunity sub-list -> their lifecycle stage is "Opportunity"
6. Test the prevention workflow: create a test contact with no lifecycle stage, wait a few minutes, confirm it gets set to "Lead". Delete the test contact.
7. Funnel reports now show all records with no "unknown" bucket

## Rollback

- The execute script's CSV audit trail records every record it changed with the original stage value. To undo, batch-update those IDs back to their previous stage (remember: moving backward requires the same clear-then-set pattern).
- Turn off the prevention workflows to stop automatic stage assignment.

## Key Technical Learnings

- **Forward-only progression is the biggest gotcha.** Direct API updates to an "earlier" stage are silently rejected. You MUST clear first, then set. This applies to both API and workflow actions.
- **"Lead" is the safest default.** It is early in the progression and will not block forward movement from workflows or deal progression. "Subscriber" is NOT a good default unless you know the contacts subscribed to a newsletter.
- **Context-aware assignment matters.** Setting a contact at a Customer company to "Lead" instead of "Customer" degrades data quality. Take the time to check associated company context.
- **Prevention is more important than cleanup.** The prevention workflows ensure the problem never recurs. Without them, new records from imports, integrations, or manual entry will immediately re-create the gap.
- **Lifecycle stage and deals interact.** HubSpot can automatically advance lifecycle stage when deals are created or won. Your prevention workflows will not interfere because they only trigger when lifecycle stage is unknown.
- **Batch edit limitations.** The UI may time out on very large bulk edits. Process one page at a time, or use the API approach for large volumes.
- **The sub-list approach is important.** Do not skip context-aware assignment and set everyone to "Lead". Contacts associated with Customer or Opportunity companies deserve the correct stage.

---

<!-- skill: standardize-geo-values | category: data-enrichment | scripts: before,execute,after -->

---
name: standardize-geo-values
description: "Convert inconsistent country and state/region formats to standardized values across contacts and companies. Ensures geographic segmentation works reliably."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Standardize Country and State/Region Values

Convert inconsistent geographic formats (e.g., "US", "USA", "U.S." vs "United States"; "NY" vs "New York") to a single standard format across all contact and company records.

## Why This Matters

Inconsistent geo values break geographic segmentation. A list filtering for "United States" will miss contacts labeled "US" or "USA". For B2B companies running region-specific campaigns or reporting by geography, this means inaccurate audience sizes and missed contacts.

## Prerequisites

- Phase 1 hygiene processes completed (invalid/deleted contacts removed first)
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact and company read/write scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Access to Contacts and Companies views with bulk edit permissions
- **Key constraint:** If your CRM integrates with another system (e.g., Salesforce, marketing automation), agree on the standard format (full names vs. ISO codes) with that system's admin BEFORE standardizing. Mismatched formats between synced systems will cause ongoing data conflicts.
- Decision on standard format. This skill recommends:
  - Countries: Full names (e.g., "United States", "United Kingdom")
  - States (contact level): Full names (e.g., "New York", "California")
  - This matches HubSpot's default form behavior

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: What format do you prefer for country values -- full names (United States) or ISO codes (US)?**
- Examples: Full names ("United States", "United Kingdom"), ISO 2-letter codes ("US", "GB"), ISO 3-letter codes ("USA", "GBR")
- Default: Full names (e.g., "United States", "United Kingdom") -- this matches HubSpot's default form behavior

**Q2: Do you have a Salesforce or other CRM integration that requires a specific format?**
- Examples: "Yes, Salesforce uses ISO 2-letter codes", "Yes, our ERP uses full country names", "No integrations to worry about"
- Default: No integration constraints -- use HubSpot's default full-name format

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/standardize-geo-values/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/standardize-geo-values/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/standardize-geo-values/scripts/after.py` |

`before.py` counts contacts and companies per variant value. `execute.py` applies the variant-to-standard mapping via batch update (review and extend its `COUNTRY_MAPPING`/`STATE_MAPPING` tables first). `after.py` verifies all variants return zero.

## Plan

1. Audit all non-standard country and state values (before state)
2. Build a mapping table of variants to standard values
3. Batch update via API script or manual bulk edit in HubSpot UI
4. Prevent future inconsistencies by configuring property types and forms
5. Verify all values are standardized (after state)

## Before

### API Audit Script

Run `uv run skills/standardize-geo-values/scripts/before.py`. The core query pattern:

```python
variants = ["US", "USA", "U.S.", "U.S.A.", "America"]
for object_type in ["contacts", "companies"]:
    for variant in variants:
        resp = requests.post(f"{BASE}/crm/v3/objects/{object_type}/search",
                             headers=HEADERS, json={
            "filterGroups": [{"filters": [
                {"propertyName": "country", "operator": "EQ", "value": variant},
            ]}],
            "limit": 1,
        })
        total = resp.json()["total"]
        if total:
            print(f"{object_type} with country = '{variant}': {total}")
```

### Manual Audit

1. Go to Contacts > filter by Country/Region > is any of > "US". Note count.
2. Repeat for "USA", "U.S.", and any other suspected variants.
3. Repeat at the company level.
4. For states: filter by State/Region > is any of > "NY", "CA", "TX" to check for abbreviation variants.

Record all variant counts as your baseline.

## Execute

### Method 1: API Batch Update (Recommended for Large Volumes)

This is scripted: `uv run skills/standardize-geo-values/scripts/execute.py`. The mapping tables it applies:

```python
COUNTRY_MAPPING = {
    "US": "United States",
    "USA": "United States",
    "U.S.": "United States",
    "U.S.A.": "United States",
    "America": "United States",
    "UK": "United Kingdom",
    "GB": "United Kingdom",
    "Great Britain": "United Kingdom",
    # Add other mappings as discovered in your audit
}

STATE_MAPPING = {
    "NY": "New York",
    "CA": "California",
    "TX": "Texas",
    "FL": "Florida",
    "IL": "Illinois",
    "PA": "Pennsylvania",
    "OH": "Ohio",
    "GA": "Georgia",
    "NC": "North Carolina",
    "NJ": "New Jersey",
    "VA": "Virginia",
    "WA": "Washington",
    "MA": "Massachusetts",
    "AZ": "Arizona",
    "CO": "Colorado",
    "MD": "Maryland",
    "MN": "Minnesota",
    "MO": "Missouri",
    "WI": "Wisconsin",
    "CT": "Connecticut",
    "OR": "Oregon",
    "SC": "South Carolina",
    "LA": "Louisiana",
    # Add all 50 US states + territories as needed
}

# For each mapping the script:
# 1. Searches for contacts with the variant value (POST /crm/v3/objects/contacts/search)
# 2. Collects all matching contact IDs (paginated via the `after` cursor)
# 3. Batch updates them (POST /crm/v3/objects/contacts/batch/update, 100 per call)
# 4. Repeats for companies (/crm/v3/objects/companies/...)
```

**API notes:**
- Search API caps at 10,000 results per query. Unlikely to hit this for geo variants, but segment if needed.
- Batch update accepts up to 100 records per call.
- Rate limit: 100 requests per 10 seconds.

### Method 2: Manual Bulk Edit in HubSpot UI

For each variant:

1. Go to **Contacts > Lists > Create list** (static)
2. Filter: Country/Region > is any of > [variant value]
3. Save list
4. Select all contacts in the list
5. Click **Edit** > Country/Region > type the standard value > Update
6. Repeat for each variant

For companies, do the same from the Companies view using filters and bulk edit.

### Prevent Future Inconsistencies

After standardizing existing data:

1. Go to **Settings > Properties > Contact properties**
2. Search for **Country/Region**
3. Verify it is a **Dropdown select** field (not free text). If it is free text, consider converting to dropdown with standard country names.
4. Repeat for **State/Region** (though state may need values for multiple countries, making a dropdown less practical)
5. Check all active **Forms** (Marketing > Forms):
   - Verify country and state fields are dropdown fields, not free text inputs
   - Verify dropdown values match your standardized format
6. Check any **import templates** used by the team to ensure they reference standard values

## After

Re-run the variant checks: `uv run skills/standardize-geo-values/scripts/after.py`. Every variant should return 0 for both contacts and companies.

**Verification checklist:**

1. All known variant values return 0 contacts and 0 companies
2. The standard value count should equal the sum of (previous standard count + all variant counts)
3. Spot-check 10 contacts that were in cleanup lists to verify values are now standardized
4. For states: filter by common abbreviations (NY, CA, TX) and confirm 0 results
5. Forms are configured to prevent future inconsistencies

## Rollback

- The execute script's CSV audit trail records every record it changed with the original value. To undo, batch-update those IDs back to their previous values.
- Individual values can also be reverted from each record's property history.

## Key Technical Learnings

- **Do not touch blank/empty values in this process.** Filling in missing country data is a separate enrichment task. This process only standardizes existing values that are non-empty but non-standard.
- **Coordinate with integrated systems first.** If HubSpot syncs with Salesforce or another CRM, mismatched formats cause sync conflicts. Agree on the standard format before changing anything.
- **Company-level state abbreviations may be acceptable.** HubSpot's default behavior for company State/Region often uses abbreviations. Decide whether to standardize company states or leave them as-is.
- **Root cause matters as much as cleanup.** The variants were likely created by imports, API integrations, or free-text form fields that bypassed dropdowns. Fixing the root cause (forms, import templates, integration mappings) is as important as fixing existing data.
- **Export before editing (optional safety measure).** Before bulk editing, export affected contacts/companies with their current values as a backup CSV.
- **Bulk edit limits vary by plan.** HubSpot may limit bulk edits to certain batch sizes (100-250 at a time). For large numbers, you may need to repeat the select-all-and-edit process multiple times, or use the API approach instead.

---

<!-- skill: waterfall-enrich-contacts | category: data-enrichment | scripts: before,execute,after -->

---
name: waterfall-enrich-contacts
description: "Enrich HubSpot contacts (email, phone, job title) through an external enrichment provider and write results back safely. Pluggable provider adapters with FullEnrich waterfall enrichment as the default; Apollo, Hunter, and Dropcontact included; bring your own via a template."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: data-enrichment
---

# Waterfall-Enrich Contacts with External Providers

Fill missing emails, phone numbers, and job titles on HubSpot contacts using an external enrichment provider, then write results back with a full audit trail. The provider layer is pluggable: **FullEnrich** (a waterfall aggregator that queries 20+ upstream sources until one hits) is the default, with Apollo, Hunter, and Dropcontact adapters included and a template for whatever provider your team already pays for.

## Why This Matters

The internal enrichment skills (`/enrich-company-name`, `/enrich-industry`, `/backfill-geo-data`) only move data the portal already has. When a contact's email, direct dial, or title simply isn't anywhere in HubSpot, external enrichment is the only fix — and it costs real money per lookup, which is why this skill is built around cost caps, previews, and typed confirmations.

## Provider Landscape

| Provider | Adapter | Strength | Model |
|----------|---------|----------|-------|
| **FullEnrich** (default) | `providers/fullenrich.py` | Waterfall across 20+ sources — best hit rates for email + mobile | Credits per lookup, async bulk API |
| Apollo | `providers/apollo.py` | Large B2B database, titles + firmographics | Credits; personal-data reveals plan-gated |
| Hunter | `providers/hunter.py` | Email finding by name+domain, confidence scores | Requests per plan; email only |
| Dropcontact | `providers/dropcontact.py` | GDPR-first, algorithmic (no stored database) | Credits, async |
| Your provider | copy `providers/_template.py` | Whatever you already use | — |
| Mock (testing only) | `providers/mock.py` | Deterministic fake data for `/sandbox-self-test` and dry runs — no network | Free; never use on production |
| HubSpot Breeze Intelligence | (native, no adapter) | In-platform enrichment + form shortening | Credit add-on; programmatic API access is enterprise-gated — which is exactly why this skill defaults to provider-agnostic adapters |

Switch providers with one env var: `ENRICHMENT_PROVIDER=apollo`.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact read/write scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- An account + API key with your chosen provider (e.g. `FULLENRICH_API_KEY` from FullEnrich dashboard > Settings > API)
- **A compliance check**: enrichment sends contact names and company data to a third party and imports personal data (emails, phones). Confirm this fits your data processing agreements and the applicable privacy rules (GDPR/CCPA) *before* running.

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/waterfall-enrich-contacts/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/waterfall-enrich-contacts/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/waterfall-enrich-contacts/scripts/after.py` |

Provider adapters live in [`scripts/providers/`](./scripts/providers/) — one module per provider implementing `enrich(contacts) -> results` (see `_template.py` for the contract).

## Configuration

Everything is set in `.env`:

```
HUBSPOT_ACCESS_TOKEN=pat-na1-xxxxxxxx
ENRICHMENT_PROVIDER=fullenrich          # fullenrich | apollo | hunter | dropcontact | mock | yours
FULLENRICH_API_KEY=...                  # the chosen provider's key
ENRICHMENT_TARGET_FIELD=phone           # phone | email | jobtitle
ENRICHMENT_MAX_CONTACTS=100             # hard cap per run — credits cost money
ENRICHMENT_OVERWRITE=false              # never overwrite existing values (default)
ENRICHMENT_CREDITS_PER_CONTACT=1        # for before.py's cost preview
```

## Execution Pattern

### Stage 1: Plan

1. Choose the provider and the target field (a phone backfill and an email backfill are separate runs).
2. Confirm the compliance check above with whoever owns data privacy.
3. Confirm budget: `MAX_CONTACTS × credits-per-lookup` is the per-run ceiling. Start with a small run (25-50) and inspect quality before scaling.

### Stage 2: Before

```bash
uv run skills/waterfall-enrich-contacts/scripts/before.py
```

Counts candidates (contacts with first name + last name + company but missing the target field) and prints a cost ceiling. Read-only.

### Stage 3: Execute

```bash
uv run skills/waterfall-enrich-contacts/scripts/execute.py
```

The script:
1. Selects up to `MAX_CONTACTS` candidates via the Search API
2. Asks for typed confirmation (`ENRICH`) **before spending credits**
3. Calls the provider adapter (async providers poll until done)
4. Computes writes — existing non-empty HubSpot values are never overwritten unless `ENRICHMENT_OVERWRITE=true`; skipped values are still recorded in the audit CSV
5. Asks for a second typed confirmation (`WRITE`) before touching HubSpot
6. Batch-updates contacts and writes the audit CSV (old value, new value, action, source per field)

### Stage 4: After

```bash
uv run skills/waterfall-enrich-contacts/scripts/after.py
```

Compares candidate counts against the baseline, then **spot-check 10-20 enriched contacts by hand** — provider quality varies by segment, and the audit CSV tells you exactly what was written where.

## Safety Mechanisms

| Mechanism | Detail |
|-----------|--------|
| **Per-run cap** | `MAX_CONTACTS` (default 100) bounds credit spend per run. Deliberately low — raise it only after verifying quality. |
| **No-overwrite default** | Existing non-empty values are never replaced unless `ENRICHMENT_OVERWRITE=true`. Enrichment fills gaps; it does not correct data. |
| **Double confirmation** | Typed `ENRICH` before credits are spent; typed `WRITE` before HubSpot is touched. Aborting between the two costs credits but changes nothing. |
| **CSV audit trail** | Every field written (and every skip) recorded with old value, new value, and provider source. |
| **Rollback data** | The audit CSV's `old` column is the rollback: batch-update those values back to undo a run. |

## Rollback

- The execute audit CSV records the previous value of every field it wrote. To undo, batch-update those contact/field pairs back to the `old` values (empty string clears a field).
- Values are also individually recoverable from each contact's property history.

## Technical Gotchas

1. **Verify adapter payloads against current provider docs.** Provider APIs move fast; each adapter's docstring links the docs and flags what to check. The adapters fail loudly (clear `SystemExit` messages) on auth or credit errors before touching HubSpot.
2. **Waterfall providers are asynchronous.** FullEnrich and Dropcontact return results in seconds-to-minutes; the adapters poll. Don't kill the script mid-poll — credits are consumed at submission.
3. **Enriched emails are unverified senders' risk.** A found email is not consent to market. New emails enter as non-marketing data points; your normal opt-in and deliverability rules apply before any sends.
4. **Match rates of 40-70% are normal.** Providers can't find everyone. The audit CSV separates "provider found nothing" (absent) from "found but skipped" (existing value).
5. **Domain quality drives hit rates.** Candidates whose email domain or company website is missing enrich poorly. Run `/enrich-company-name` first — better identity inputs, better waterfall results.
6. **Internal-data-first.** If the value exists anywhere in the portal (associated company, `ip_country`, form submissions), the free internal skills should fill it — save credits for data HubSpot genuinely doesn't have.

---

<!-- skill: build-lead-scoring | category: segmentation-scoring | scripts: none -->

---
name: build-lead-scoring
description: "Create a comprehensive lead scoring model with separate Fit and Engagement scores using HubSpot's new Lead Scoring tool. Replaces the deprecated HubSpot Score property."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: segmentation-scoring
---

# Build Lead Scoring Model

Create a two-score lead scoring model using HubSpot's new Lead Scoring tool: a Fit score (ICP company fit + persona match) and an Engagement score (behavioral signals with time decay). This enables sales to prioritize by company fit and marketing to prioritize by engagement recency.

## Why This Matters

Without scoring, every lead looks equally (un)important. Sales has no ranked list of who to call first, marketing cannot trigger stage progressions based on engagement, and there is no way to differentiate between a senior decision-maker at a target-vertical enterprise and a generic contact who has never opened an email.

## Prerequisites

- Super Admin permissions in HubSpot
- HubSpot Marketing Hub Professional or Enterprise
- ICP Tier property created and workflows processed (create-icp-tiers skill must be completed first)
- Access to **Marketing > Lead Scoring** (the new tool, NOT the deprecated "HubSpot Score" property)

## Critical: Old vs New Lead Scoring

**The legacy "HubSpot Score" property was retired on August 31, 2025** — it stopped being editable in July 2025 and stopped updating entirely at the retirement date. Any workflow, list, or report still referencing `hubspotscore` has been running on frozen data since then; part of this skill's job is to find and repoint those references.

The **Lead Scoring tool** (Marketing > Lead Scoring) supports:
- Layered architecture: total score limit → group limits → rules → criteria
- Engagement decay (points reduce over time automatically)
- Separate Fit vs Engagement score types, with time frames and thresholds
- Scoring for **contacts, companies, and deals**
- Up to 5 total scores per portal

**Scores are API-readable.** Configuring scoring rules is still UI-only — there is no scoring configuration API — but each score you create auto-generates CRM properties (the score value and a threshold property). Those properties are readable and filterable through the CRM Search API like any other property, which is what the After stage and the `/lifecycle-progression-workflow` scripts rely on. Find the internal property names under Settings > Properties after creating each score.

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: What job titles/personas are most valuable to you?**
- Examples: CEO, COO, CFO, CTO, CRO, VP of Operations, VP of Marketing, Director of Operations, Director of Marketing, Head of Procurement, Engineering Manager
- Default: C-suite and VP-level leaders get the highest scores, followed by Director and Manager-level roles

**Q2: What engagement actions matter most?**
- Examples: Email opens, email clicks, form submissions, website visits, content downloads, webinar registrations
- Default: Form submissions (+30), email clicks (+25), website visits (+20), email opens (+15)

**Q3: What negative signals should reduce scores?**
- Examples: Unsubscribe, hard bounce, competitor domain, no activity in 6+ months, free email domain (gmail, yahoo)
- Default: Global unsubscribe (-100), hard bounce (-50), no activity 6+ months (-20), missing company name (-10)

**Q4: What score threshold should trigger MQL status?**
- Examples: Fit > 30 AND Engagement > 20, combined score > 50, any threshold that matches your sales handoff criteria
- Default: Fit Score > 30 AND Engagement Score > 20

## Plan

1. Review any existing scoring models in the portal
2. Create the Fit Score (company fit + persona match)
3. Create or update the Engagement Score (behavioral signals with decay)
4. Allow 4-6 hours for HubSpot to recalculate all contacts
5. Verify scoring distribution and accuracy (after state)

## Before

1. Navigate to **Marketing > Lead Scoring**
2. Note any existing scores (you have a limit of 5 total)
3. Review existing score criteria — decide whether to update or replace
4. Check that ICP Tier property is fully populated on companies (run create-icp-tiers after state check)
5. Search for leftover references to the retired `hubspotscore` property in workflows, lists, and reports — these have been frozen since August 2025 and must be repointed to the new score properties

## Execute

### Create the Fit Score

1. Go to **Marketing > Lead Scoring**
2. Click **Create score**
3. Select **Fit** as the score type
4. Select **Contact** as the scored object
5. Name it descriptively (e.g., "Lead Fit Score")

#### Score Group 1: ICP Company Tier

Use **Associated company property > ICP Tier**:

These are starting points -- calibrate based on your actual conversion data after 30 days.

| Criteria | Condition | Points (suggested range) |
|----------|-----------|------------------------|
| Primary ICP Company | ICP Tier is "Tier 1 - Primary ICP" | +25 to +35 |
| Secondary ICP Company | ICP Tier is "Tier 2 - Secondary ICP" | +15 to +25 |
| Tertiary ICP Company | ICP Tier is "Tier 3 - Tertiary ICP" | +5 to +15 |
| Not ICP Company | ICP Tier is "Not ICP" | -10 to -20 |

#### Score Group 2: Persona / Job Title

Use **Contact property > Job title > contains any of**:

These are starting points -- adjust titles and weights to match your buyer personas.

| Criteria | Example Title Values | Points (suggested range) |
|----------|---------------------|------------------------|
| C-Suite Executives | CEO, COO, CFO, CTO, CRO, CMO, Chief Revenue Officer | +20 to +30 |
| VP-Level Leaders | VP of Operations, VP of Marketing, VP of Sales, VP of Finance | +20 to +30 |
| Director-Level | Director of Operations, Director of Marketing, Head of Procurement, Director of Finance | +15 to +25 |
| Manager-Level | Engineering Manager, Operations Manager, Marketing Manager, Procurement Manager | +10 to +20 |
| Other Relevant Titles | Analyst, Coordinator, Specialist (if relevant to your sales process) | +5 to +10 |

Customize these titles based on your buyer personas. The point values should reflect how likely each persona is to be a decision-maker or champion for your product. The ranges above are starting points -- review after 30 days and adjust based on which titles actually convert.

#### Score Group 3: Negative Fit Signals

| Criteria | Condition | Points |
|----------|-----------|--------|
| Missing Company Name | Company name is unknown | -10 |
| Hard Bounced | Hard bounce reason is known | -50 |
| Globally Unsubscribed | Unsubscribed from all email = True | -100 |

6. Set the overall score maximum (recommended: 100)
7. Save and turn ON

### Create the Engagement Score

1. Click **Create score** (or edit existing engagement score)
2. Select **Engagement** as the score type
3. Select **Contact** as the scored object
4. Name it descriptively (e.g., "Lead Engagement Score")

#### Positive Engagement Criteria

| Criteria | Condition | Points | Decay |
|----------|-----------|--------|-------|
| Opened Marketing Email | Last marketing email open date within last 30 days | +15 | Monthly |
| Clicked Marketing Email | Last marketing email click date within last 30 days | +25 | Monthly |
| Visited Website | Number of Sessions > 0 | +20 | Quarterly |
| Submitted a Form | Number of Form Submissions > 0 | +30 | Quarterly |

#### Negative Engagement Criteria

| Criteria | Condition | Points |
|----------|-----------|--------|
| No Email Activity 6+ Months | Last marketing email open date > 180 days ago | -20 |

5. Set the overall score maximum (recommended: 100)
6. Save and turn ON

### Example Combined Scoring Framework

For reference, here is how the two scores work together to prioritize contacts:

| Contact Profile | Fit Score | Engagement Score | Priority |
|----------------|-----------|-----------------|----------|
| CEO at Tier 1 company, clicked email this week | ~60 | ~55 | Highest |
| Director of Operations at Tier 2 company, form submission | ~40 | ~50 | High |
| Unknown title at Tier 3 company, email open only | ~10 | ~15 | Medium |
| No title, Not ICP, no activity in 6 months | ~-25 | ~-20 | Lowest |

### For Lifecycle Progression

If you want to automatically progress contacts through lifecycle stages based on scoring:

- Define a combined threshold (e.g., Fit Score > 30 AND Engagement Score > 20 = MQL; typically the combined threshold falls in the 40-60 range, but calibrate based on your pipeline)
- Build this as a separate workflow — `/lifecycle-progression-workflow` creates it via the v4 Automation API; set its `LEAD_SCORE_PROPERTY` config to your new score property's internal name
- This is a separate task from building the scoring model

## After

**Allow 4-6 hours for HubSpot to fully recalculate all contact scores.** The new Lead Scoring tool processes asynchronously, and large databases take time.

### Verification

1. Go to **Contacts > Contacts**
2. Click **Edit columns** and add both score properties to visible columns
3. Sort by Fit Score descending

**Check the top 20 contacts:**
- Job titles should be target personas (CEO, VP of Operations, Director of Marketing, etc.)
- Associated companies should be Tier 1 or Tier 2
- If a non-relevant contact appears at the top, review the scoring criteria for issues

**Check the bottom contacts:**
- Sort ascending (lowest scores first)
- Bottom contacts should be unsubscribed, bounced, or at Not ICP companies
- If relevant contacts appear at the bottom, review negative signal weights

**Check score distribution:**
- Filter Fit Score > 50: High-priority fit (should be your best prospects)
- Filter Fit Score 20-50: Medium fit
- Filter Fit Score 1-19: Low fit
- Filter Fit Score <= 0: Disqualified (should be unsubscribed, bounced, or bad data)

**Sanity check:**
- Pick 3 contacts at random
- Manually calculate their expected scores based on your criteria
- Compare to actual scores
- Investigate any discrepancies

**Score distribution via API** (the score properties are searchable like any property — replace the property name with your score's internal name from Settings > Properties):

```python
resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "your_fit_score_property", "operator": "GTE", "value": "50"},
    ]}],
    "limit": 1,
})
print(f"Contacts with Fit Score >= 50: {resp.json()['total']}")
```

## Rollback

- Scores can be turned off or deleted in Marketing > Lead Scoring. Deleting a score removes its generated properties — check that no workflows or lists reference them first (especially `/lifecycle-progression-workflow` and any "MQL Ready" lists).
- The retired legacy `hubspotscore` property cannot be revived; there is nothing to roll back to.

## Key Technical Learnings

- **The legacy "HubSpot Score" property is gone (retired August 2025).** Anything still referencing `hubspotscore` runs on frozen data. Repoint it to the new score properties.
- **Score properties are API-readable, configuration is not.** Use the Search API to verify distributions and drive automation off score properties; build and tune the rules in the UI.
- **Two separate scores are better than one.** Fit and Engagement serve different purposes: Fit tells you WHO to talk to (company and persona match), Engagement tells you WHEN to talk to them (behavioral recency). Combining into one number obscures both signals.
- **Score decay is a major improvement.** Enable it on engagement criteria so scores naturally decrease over time. Without decay, a contact who clicked one email two years ago looks the same as one who clicked yesterday.
- **Allow 4-6 hours for recalculation.** Do not panic if scores show 0 immediately after creation. The new tool processes asynchronously across the entire database.
- **Limit of 5 scores per portal.** Plan carefully. You may want to reserve slots for future scores (e.g., product-specific engagement scores).
- **Tune the model after 30 days.** Review whether top-scored contacts are actually converting. Adjust point values based on real conversion data. Lead scoring is iterative, not one-and-done.
- **Negative signals are as important as positive ones.** Hard bounces and global unsubscribes should carry heavy negative weight to push these contacts to the bottom regardless of other factors.
- **ICP Tier is the highest-leverage scoring input.** It captures firmographic fit in a single property. Without it, the Fit score has no company-level signal and relies entirely on persona matching.

---

<!-- skill: build-smart-lists | category: segmentation-scoring | scripts: execute -->

---
name: build-smart-lists
description: "Create foundational segmented lists for marketing and sales operations via the Lists API, plus advanced UI-built segments: a master sendable list, ICP-based lists, persona lists, and engagement lists. All active (dynamic) lists."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: segmentation-scoring
---

# Build Smart Lists for Segmentation

Create the core active (dynamic) lists that serve as the foundation for all marketing campaigns, sales prioritization, and database health monitoring. Ten foundation lists are created automatically via the Lists API (v3); a further set of advanced segments is built in the UI.

## Why This Matters

Without predefined lists, every email campaign requires building filters from scratch, there is no standardized definition of "who can we actually email", and there is no persona-based segmentation. The marketing team cannot quickly answer basic questions like "How many senior decision-makers can we email right now?" or "How many engaged contacts do we have?"

## Prerequisites

- Super Admin or Marketing Hub Admin permissions
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.lists.read` and `crm.lists.write` scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- ICP Tier property created and workflows processed (create-icp-tiers skill) — the script assumes the property name `company_segment` with values like `tier_1_primary_icp`; adjust its configuration block if yours differ
- Lead scoring model created (build-lead-scoring skill) is recommended but not required
- Lifecycle Stage property populated for customers and partners (fix-lifecycle-stages skill)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/build-smart-lists/scripts/execute.py` |

`execute.py` creates the 10 foundation lists below via `POST /crm/v3/lists`, skips lists that already exist (409), and writes a CSV audit trail of created list IDs. There are no before/after scripts: the before-state inventory is a single Lists API query (see Before), and verification is a member-count review in the UI.

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: What defines "engaged" for your business? (e.g., activity in last 60-120 days)**
- Examples: Email open or click in last 90 days, website visit in last 60 days, form submission in last 30 days
- Default: Email open or click in the last 90 days (set `ACTIVE_WINDOW_DAYS` in the script; shorter cycles suit high-velocity sales, longer cycles suit enterprise)

**Q2: What job titles represent your target personas?**
- Examples: CEO, COO, CFO, CTO, CRO, VP of Operations, VP of Marketing, Director of Operations, Director of Marketing, Head of Procurement, Engineering Manager
- Default: C-suite and VP/Director-level leaders across business functions (used for the UI-built persona list)

## The List Library

### Foundation lists (created by the script)

| # | List Name | Object | Key Filter |
|---|-----------|--------|-----------|
| 1 | All Marketing Contacts | Contacts | `hs_marketable_status` = true |
| 2 | All Leads | Contacts | Lifecycle stage = Lead |
| 3 | All MQLs | Contacts | Lifecycle stage = MQL |
| 4 | All SQLs | Contacts | Lifecycle stage = SQL |
| 5 | All Customers | Contacts | Lifecycle stage = Customer |
| 6 | ICP Tier 1 Companies | Companies | ICP Tier = Tier 1 |
| 7 | ICP Tier 2 Companies | Companies | ICP Tier = Tier 2 |
| 8 | ICP Tier 3 Companies | Companies | ICP Tier = Tier 3 |
| 9 | Engaged Last 90 Days | Contacts | Email open OR click within the active window |
| 10 | Unengaged 180+ Days | Contacts | Marketable + no email open beyond the re-engagement window |

### Advanced segments (built in the UI)

These use features that benefit from human judgment and portal-specific configuration (list-membership filters, persona keywords, form-name keywords):

| # | List Name | Purpose | Key Filters |
|---|-----------|---------|-------------|
| A | Marketable - Active | Master sendable list (who CAN receive email) | Marketing contact + not unsubscribed + not bounced + has email + not quarantined |
| B | ICP Tier 1 Contacts | Highest priority *contacts* | Associated company ICP Tier = Tier 1 + member of Marketable - Active |
| C | ICP Tier 2 Contacts | Secondary priority contacts | Associated company ICP Tier = Tier 2 + member of Marketable - Active |
| D | Partners | Partner communications and exclusion | Lifecycle stage = Partner (or custom contact type property) |
| E | Re-engagement Needed | Sunset candidates | 5+ emails delivered + no open in 180 days + member of Marketable - Active |
| F | Senior Decision Makers | Top persona list | Job title contains target titles |
| G | Industry Leaders | Contacts at companies in target verticals | Associated company industry is any of target industries |
| H | Content Engaged | Form submissions and content downloads | Form submissions > 0 OR conversion contains content keywords |

Overlap note: "All Customers" (script) covers the Customers use case; "Unengaged 180+ Days" (script) is a simpler variant of "Re-engagement Needed" (UI, adds the delivered-count guard). Keep whichever fits your operation — do not maintain both without a reason.

## Plan

1. Run the interview above; set `ACTIVE_WINDOW_DAYS`, `REENGAGEMENT_WINDOW_DAYS`, and `ICP_PROPERTY_NAME` in the script's configuration block
2. Decide which advanced segments to build in the UI
3. Create foundation lists via the script, advanced segments via the UI
4. Verify list sizes make sense relative to the database

## Before

Inventory existing lists to avoid duplicates (`POST /crm/v3/lists/search` with an empty query returns all lists, paginated). Check for name collisions with the library above — the script skips exact name matches (HTTP 409), but near-duplicates ("Customers" vs "All Customers") need a human decision: merge, rename, or skip.

## Execute

### Step 1: Foundation lists (script)

```bash
uv run skills/build-smart-lists/scripts/execute.py
```

The script posts each definition to `POST /crm/v3/lists` with `processingType: "DYNAMIC"`. Example payload shape (see the script for all ten):

```python
{
    "name": "All Customers",
    "objectTypeId": "0-1",  # contacts ("0-2" for companies)
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "AND",
        "filterBranches": [],
        "filters": [{
            "filterType": "PROPERTY",
            "property": "lifecyclestage",
            "operation": {
                "operationType": "ENUMERATION",
                "operator": "IS_EQUAL_TO",
                "value": "customer",
            },
        }],
    },
}
```

If the Lists API returns 403 on your plan tier, build the same lists manually in the UI using the table above.

### Step 2: Marketable - Active (master sendable list — UI)

**This is the most important advanced list.** It defines the single source of truth for "who can receive marketing email." All campaign sends should reference this list.

1. Go to **Contacts > Lists > Create list**
2. Select **Contact-based > Active list**
3. Name: `Marketable - Active`
4. Add filters (all AND logic):
   - Marketing contact status > is any of > Marketing contact
   - AND Unsubscribed from all email > is not equal to > True
   - AND Hard bounce reason > is unknown
   - AND Email > is known
   - AND Email quarantined > is not equal to > True
5. Save the list

### Step 3: Remaining advanced segments (UI)

**ICP Tier 1 / Tier 2 Contacts (B, C):**
- Filters: Associated company property > ICP Tier > is any of > [tier] AND List membership > is member of > Marketable - Active
- **Using List membership as a filter** is a powerful pattern: these lists automatically inherit all deliverability and consent logic from Marketable - Active. New disqualification conditions added there propagate automatically.

**Partners (D):** Lifecycle stage > is any of > Partner. Always exclude from prospect campaigns.

**Re-engagement Needed (E):**
- Marketing emails delivered > is greater than > 5
- AND Last marketing email open date > is more than > 180 days ago (tune to your cycle: 120-270)
- AND List membership > is member of > Marketable - Active
- Feeds the engagement-based suppression workflow.

**Senior Decision Makers (F):** Job title > contains any of > [your target titles]. Customize keywords to your buyer personas.

**Industry Leaders (G):** Associated company property > Industry > is any of > [your target industries].

**Content Engaged (H):** OR logic between groups:
- Number of Form Submissions > is greater than > 0
- OR First conversion > contains any of > [content keywords like "Download", "Guide", "Checklist", "E-Book", "Whitepaper"]
- OR Recent conversion > contains any of > [same keywords]
- The conversion-based filters depend on your form naming conventions — review actual form names (Marketing > Forms) and adjust.

## After

### Verify List Sizes

After all lists are created and processed:

| List | Expected Range | Red Flag If... |
|------|---------------|----------------|
| All Marketing Contacts / Marketable - Active | 30-80% of total contacts | Below 10% (too many excluded) or above 90% (filters too loose) |
| ICP Tier lists | 2-10% of marketable | 0 (ICP Tier not populated) |
| Engaged (active window) | 5-30% of total contacts | Below 2% (possible engagement tracking issue) |
| All Customers | Known customer count | Off by more than 20% from expected |
| Partners | Known partner count | Off by more than 20% from expected |
| Unengaged / Re-engagement Needed | 10-40% of marketable | Above 60% (possible date threshold issue) |
| Senior Decision Makers | 5-25% of total contacts | 0 (job title data missing) |
| Industry Leaders | 10-50% of total contacts | 0 (industry data missing) |
| Content Engaged | 1-10% of total contacts | 0 (no form submissions or wrong keywords) |

### Verification Checklist

1. **All lists show as Active** (not Static) in the list view
2. **All lists have completed processing** (no "Processing" status)
3. **Sendable-list sanity check:** Open Marketable - Active (or All Marketing Contacts), click 5 random contacts. Each should have a valid email, not be unsubscribed, not be bounced.
4. **ICP list check:** Open an ICP Tier 1 list, click 5 records. Each should have ICP Tier = Tier 1.
5. **Persona list check:** Open Senior Decision Makers, verify job titles match expected patterns. Watch for false positives (e.g., "Marketing Intern" matching on "Marketing").
6. **Re-engagement check:** Verify contacts have 5+ emails delivered and no open beyond your configured window.

## Rollback

- Lists can be deleted via `DELETE /crm/v3/lists/{listId}` or the UI — the script's CSV audit trail records every list ID it created.
- Deleting a list does not affect the contacts in it — only the list definition is removed.
- Check whether any workflows, emails, or dependent lists (list-membership filters!) reference a list before deleting it.

## Key Technical Learnings

- **The master sendable list is the foundation.** Every email campaign should either send directly to it (with additional filters) or use it as an inclusion filter. Never send to a list that does not incorporate deliverability and consent checks.
- **List membership as a filter is a powerful pattern.** Changes to the master list's criteria automatically propagate to all dependent lists.
- **"Contains any of" for job titles is broad by design.** It matches the keyword anywhere in the title string, so "CTO" matches "CTO", "Former CTO", "Assistant to the CTO". Review lists periodically and add exclusion terms (e.g., AND Job title does not contain "Former", "Assistant", "Intern") if false positives become a problem.
- **Content and event lists depend on form naming conventions.** The keyword-based filters only work if forms follow a naming convention that includes the keywords. After creating the lists, check if counts seem too low and adjust keywords to match actual form names.
- **Active lists have a processing delay.** HubSpot processes active lists periodically (every few minutes for small lists, potentially longer for complex ones). Wait for processing to complete before judging counts.
- **Each list should be active (dynamic), not static.** Static lists are snapshots that never update. Active lists update automatically as contact properties change, which is essential for ongoing segmentation.
- **The ICP property name and values must match the create-icp-tiers skill.** The script defaults to property `company_segment` with values `tier_1_primary_icp` / `tier_2_secondary_icp` / `tier_3_tertiary_icp`. If you chose different names, update the script's configuration block before running.
- **Plan for growth.** These lists cover core use cases. As marketing operations mature, add more targeted lists: "MQL Ready" (score threshold), "Competitor Employees" (for exclusion), "Recent Form Submitters (Last 30 Days)" (for fast follow-up), or service/product-specific interest lists.
- **Build a dashboard.** Create a dashboard with one KPI tile per list showing the current count. This gives at-a-glance visibility into segment health and makes it easy to spot sudden changes (e.g., Marketable list drops 50% = something broke).

---

<!-- skill: create-icp-tiers | category: segmentation-scoring | scripts: before,execute,after -->

---
name: create-icp-tiers
description: "Classify companies into Ideal Customer Profile (ICP) tiers based on firmographic data (industry + employee count). Creates a custom property via API and 4 classification workflows in HubSpot UI."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: segmentation-scoring
---

# Create ICP Tier Property and Classification Workflows

Classify every company in the CRM into an Ideal Customer Profile tier based on firmographic data. Creates a custom dropdown property and 4 automated workflows that continuously classify companies as they enter or change.

## Why This Matters

Without ICP classification, every inbound lead looks the same regardless of whether they come from a large enterprise in a target vertical or a tiny company in an irrelevant industry. Sales and marketing have no systematic way to prioritize outreach, allocate resources, or differentiate campaigns by company fit. ICP Tier is also a major input to the lead scoring model.

## Prerequisites

- Super Admin permissions in HubSpot
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with company read/write and `crm.schemas.companies.write` scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Access to Automation > Workflows (Marketing Hub Professional or higher)
- Data enrichment processes completed (company name, industry, geo values) so company data is as complete as possible
- Company properties **Number of Employees** and **Industry** should be well-populated. Check coverage:
  - Industry: aim for 80%+ populated
  - Employee count: aim for 80%+ populated
  - Companies missing these fields will fall to "Not ICP" (conservative, intentional)

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: What industries define your ideal customer?**
- Examples: Manufacturing, Professional Services, Logistics, Retail, Education, Media & Entertainment, Hospitality, Real Estate, Agriculture
- Default: No default -- this is highly business-specific and must be provided by the user

**Q2: What employee count ranges define your tiers?**
- Examples: Tier 1: 1,000+, Tier 2: 200-999, Tier 3: 50-199, Not ICP: under 50
- Default: Tier 1: 1,000+, Tier 2: 200-999, Tier 3: 50-199

**Q3: Are there any other firmographic criteria?**
- Examples: Annual revenue thresholds, geographic restrictions (US-only, EMEA, etc.), specific technologies used, funding stage
- Default: None -- industry and employee count are the primary classification axes

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/create-icp-tiers/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/create-icp-tiers/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/create-icp-tiers/scripts/after.py` |

`before.py` checks whether the tier property exists and audits industry/employee-count coverage. `execute.py` creates the ICP Tier property (review its configuration block first). `after.py` verifies coverage and tier distribution. The classification workflows themselves are built in the UI (see Execute).

## Plan

1. Define your ICP tier criteria (industry verticals + employee count thresholds)
2. Create the ICP Tier custom property via API or UI
3. Build 4 classification workflows (Tier 1, Tier 2, Tier 3, Not ICP)
4. Activate workflows in staggered sequence
5. Verify classification results (after state)

## Before

### Define Your ICP Tiers

Before building anything, define your criteria framework:

| Tier | Label | Industry Verticals | Employee Threshold |
|------|-------|-------------------|-------------------|
| Tier 1 | Primary ICP | [Your primary verticals, e.g., Manufacturing, Professional Services, Logistics] | [e.g., 1,000+] |
| Tier 2 | Secondary ICP | [Your secondary verticals, e.g., Retail, Education, Media & Entertainment] | [e.g., 200+] |
| Tier 3 | Tertiary ICP | [Your tertiary verticals, e.g., Hospitality, Real Estate, Agriculture] | [e.g., 200+] |
| Not ICP | Not ICP | Everything else | Any |

**Size-based demotion pattern:** Companies in a higher-tier industry but below that tier's employee threshold should be demoted to the next tier down, not classified as "Not ICP". For example:
- A company in a Tier 1 industry with fewer than 1,000 but more than 200 employees -> Tier 2
- A company in a Tier 2 industry with fewer than 200 but more than 50 employees -> Tier 3
- Only companies below 50 employees in any ICP industry, or in non-ICP industries entirely, should be "Not ICP"

This ensures ICP-relevant companies are never lost due to size alone.

### Audit Current State

Run `uv run skills/create-icp-tiers/scripts/before.py`. The core queries:

```python
# Check if the ICP Tier property already exists
# Use your chosen property name (e.g., "company_segment", "buyer_tier", "icp_tier")
PROPERTY_NAME = "company_segment"
resp = requests.get(f"{BASE}/crm/v3/properties/companies/{PROPERTY_NAME}",
                    headers=HEADERS)
print("Property exists" if resp.status_code == 200 else "Property does not exist yet")

# Check data coverage for classification
for prop_name in ["industry", "numberofemployees"]:
    resp = requests.post(f"{BASE}/crm/v3/objects/companies/search", headers=HEADERS, json={
        "filterGroups": [{"filters": [
            {"propertyName": prop_name, "operator": "NOT_HAS_PROPERTY"},
        ]}],
        "limit": 1,
    })
    print(f"Companies missing {prop_name}: {resp.json()['total']}")
```

## Execute

### Step 1: Create the ICP Tier Property

Choose a property name that fits your CRM conventions (e.g., `company_segment`, `buyer_tier`, or `icp_tier`). The name is configurable -- just be consistent across workflows and lists.

**Via API (recommended):** run `uv run skills/create-icp-tiers/scripts/execute.py`, which posts:

```python
# Configure your property name here
PROPERTY_NAME = "company_segment"

resp = requests.post(f"{BASE}/crm/v3/properties/companies", headers=HEADERS, json={
    "name": PROPERTY_NAME,
    "label": "ICP Tier",
    "type": "enumeration",
    "fieldType": "select",
    "groupName": "companyinformation",
    "description": "Automated ICP classification based on industry and employee count. "
                   "Set by workflow - do not edit manually.",
    "options": [
        {"label": "Tier 1 - Primary ICP", "value": "tier_1_primary_icp", "displayOrder": 0},
        {"label": "Tier 2 - Secondary ICP", "value": "tier_2_secondary_icp", "displayOrder": 1},
        {"label": "Tier 3 - Tertiary ICP", "value": "tier_3_tertiary_icp", "displayOrder": 2},
        {"label": "Not ICP", "value": "not_icp", "displayOrder": 3},
    ],
})
resp.raise_for_status()
```

**Via UI:** Settings > Properties > Company properties > Create property > Dropdown select with the four tier options.

### Step 2: Build Classification Workflows

Build 4 company-based workflows in the HubSpot UI. Workflows must use **filter-based triggers** ("When filter criteria is met") with AND logic.

#### Building the Classification Workflows: Three Options

**Option 1: Manual UI Build.** Follow the per-workflow specifications below. This is the most reliable method and gives you full control over every trigger and action.

**Option 2: HubSpot Breeze AI.** Navigate to **Automation > Workflows > Create workflow > "Describe what you want"** and paste the following prompt (repeat for each tier, adjusting the tier name, industries, and thresholds):

```
Create a company-based workflow that triggers when filter criteria is met:
- Number of Employees is greater than or equal to [THRESHOLD]
- AND Industry is any of [LIST YOUR INDUSTRIES]
- AND the custom property "ICP Tier" is unknown

The workflow should set the custom property "ICP Tier" to "[TIER VALUE]".
Enable re-enrollment for all trigger properties.
```

For the Not ICP catch-all workflow, use:
```
Create a company-based workflow that triggers when filter criteria is met:
- Custom property "ICP Tier" is unknown

The workflow should wait a delay (30-90 minutes) to let tiered workflows process first, then set the custom property "ICP Tier" to "Not ICP".
Enable re-enrollment.
```

**CRITICAL WARNING: Breeze trigger limitations.** Breeze creates **event-based triggers (OR logic)** instead of **filter-based triggers (AND logic)**. This is especially dangerous for ICP classification because event-based triggers fire when any single property changes, regardless of other conditions, leading to incorrect tier assignments. After Breeze creates each workflow, you MUST manually verify and rebuild the triggers to use filter-based enrollment with AND logic. Breeze is best used for creating the workflow skeleton (actions, delays) -- the trigger conditions almost always need manual correction.

Additional Breeze limitations:
- Breeze **cannot** create "is unknown" filter conditions reliably -- verify that the "ICP Tier is unknown" guard is correctly configured
- Breeze **cannot** configure re-enrollment rules
- Breeze **cannot** create multiple filter groups with OR between groups and AND within groups (needed for Tier 2 and Tier 3 demotion logic)

**Option 3: Claude Anthropic Chrome Extension.** The Claude Chrome extension lets Claude see and interact with the HubSpot workflow builder UI directly. You can describe each workflow's logic in natural language and Claude will click through the UI to build it. This is often more accurate than Breeze for the ICP workflows because of their complex multi-group filter logic and the critical requirement for AND-based triggers. Build the four workflows one at a time, activating in the staggered sequence described in Step 3.

#### Workflow Specifications

#### Workflow 1: Tier 1 (Primary ICP)

- Name: `ICP TIER: Assign Tier 1 - Primary ICP`
- Trigger: When filter criteria is met
  - Number of Employees >= [your threshold, e.g., 1,000]
  - AND Industry is any of [your primary verticals and their variants]
- Re-enrollment: ON (for all trigger properties)
- Action: Set ICP Tier = "Tier 1 - Primary ICP"

**Industry variant tip:** HubSpot has multiple labels for the same vertical. For example, "Manufacturing" might appear as "Manufacturing", "Industrial Automation", "Machinery", "Electrical/Electronic Manufacturing". Include ALL relevant variants in the "is any of" filter. Check what values actually exist in your data.

#### Workflow 2: Tier 2 (Secondary ICP)

- Name: `ICP TIER: Assign Tier 2 - Secondary ICP`
- Trigger: When filter criteria is met

**Filter Group 1 — Secondary industries at threshold:**
- Number of Employees >= [e.g., 200]
- AND Industry is any of [your secondary verticals and variants]
- AND ICP Tier is unknown (prevents overwriting Tier 1)

**Filter Group 2 — Primary industries demoted by size:**
- Number of Employees >= [e.g., 200]
- AND Number of Employees <= [e.g., 999]
- AND Industry is any of [your primary verticals and variants]
- AND ICP Tier is unknown

- Re-enrollment: ON
- Action: Set ICP Tier = "Tier 2 - Secondary ICP"

#### Workflow 3: Tier 3 (Tertiary ICP)

- Name: `ICP TIER: Assign Tier 3 - Tertiary ICP`
- Trigger: When filter criteria is met
- Multiple filter groups for:
  - Tertiary industries at threshold
  - Primary industries demoted by size (below Tier 2 threshold)
  - Secondary industries demoted by size (below Tier 2 threshold)
  - Each group includes AND ICP Tier is unknown
- Re-enrollment: ON
- Action: Set ICP Tier = "Tier 3 - Tertiary ICP"

#### Workflow 4: Not ICP (Catch-All)

- Name: `ICP TIER: Assign Not ICP`
- Trigger: When filter criteria is met
  - ICP Tier is unknown
- Re-enrollment: ON
- Action 1: **Delay (30-90 minutes)** to let tiered workflows process first
- Action 2: Set ICP Tier = "Not ICP"

### Step 3: Activation Sequence

**Activate workflows in staggered sequence to prevent race conditions:**

1. Activate Tier 1 workflow -> enroll existing companies
2. Wait a few minutes (3-10 minutes)
3. Activate Tier 2 workflow -> enroll existing companies
4. Wait a few minutes (3-10 minutes)
5. Activate Tier 3 workflow -> enroll existing companies
6. Wait a few minutes (3-10 minutes)
7. Activate Not ICP workflow -> enroll existing companies

The stagger ensures higher-priority tiers process first. The "ICP Tier is unknown" filter on Tiers 2-4 prevents overwriting, and the delay on Not ICP provides additional buffer.

## After

Wait 2-4 hours for all workflows to process, then verify: `uv run skills/create-icp-tiers/scripts/after.py` (checks that 0 companies lack a tier and prints the distribution per tier value).

**Verification checklist:**

1. **Total coverage:** 0 companies with ICP Tier unknown
2. **Distribution sanity:** Tier 1 should be the smallest group, Not ICP the largest. If Tier 1 is huge, the employee threshold may be too low or industry list too broad.
3. **Spot-check Tier 1:** Open 5-10 Tier 1 companies. Confirm each has the correct employee count and industry.
4. **Spot-check demotions:** Find Tier 2 companies in primary ICP industries. Confirm they have employee counts below the Tier 1 threshold but above the Tier 2 threshold.
5. **Spot-check Not ICP:** Open 5-10 Not ICP companies. Confirm each has at least one disqualifying factor (low employee count, non-ICP industry, or missing data).
6. **Check workflow errors:** Review each workflow's history for failures.

## Rollback

- Turn off the four classification workflows to stop further tier assignment.
- To remove classification entirely: archive the ICP Tier property (`DELETE /crm/v3/properties/companies/{name}` archives it; values are recoverable by unarchiving). Check that no lists, reports, or scoring models reference it first.

## Key Technical Learnings

- **CRITICAL: Breeze AI creates wrong trigger types.** Breeze generates event-based triggers (OR logic between groups) instead of filter-based triggers (AND logic within groups). Event-based triggers mean any single property change enrolls the company regardless of other conditions. Always verify triggers manually or build them from scratch.
- **Activation sequence prevents race conditions.** Stagger activation (Tier 1 first, then 2, then 3, then Not ICP) so higher-priority tiers claim companies before lower tiers. The "ICP Tier is unknown" filter provides additional protection.
- **The delay on Not ICP is critical.** Without it, the catch-all workflow would classify companies as Not ICP before tiered workflows have a chance to process them. A delay of 30-90 minutes is typical.
- **Size-based demotion is the key design pattern.** Never classify a company in an ICP industry as "Not ICP" just because it is smaller than the primary threshold. Demote to the next tier instead. This preserves ICP-adjacent companies for secondary targeting.
- **Companies with missing data fall to Not ICP.** This is intentional and conservative. If you later enrich company data (via data provider, manual entry, or Breeze Intelligence), the re-enrollment triggers automatically reclassify those companies.
- **Do not manually edit ICP Tier values.** The workflows manage this property. If you need exceptions, create a separate "ICP Tier Override" property and adjust workflows to respect it.
- **Include industry label variants.** HubSpot has multiple labels for the same vertical (e.g., "Manufacturing" vs "Industrial Automation" vs "Machinery" vs "Electrical/Electronic Manufacturing"). Check what values actually exist in your data and include all relevant variants in your workflow filters.
- **Validate with a script after Breeze creates workflows.** If your HubSpot API token has the `automation-access` scope, you can read workflow definitions via the Workflows API and programmatically verify that triggers match your spec.

---

<!-- skill: bounce-monitoring-workflow | category: automation-workflows | scripts: before,execute,after -->

---
name: bounce-monitoring-workflow
description: "Build a workflow to protect sender reputation through automated bounce monitoring. Auto-suppresses contacts above a configurable bounce threshold, alerts on hard bounces, and flags high-bounce contacts for weekly manual review."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: automation-workflows
---

# Bounce Monitoring Workflow

Protect your email sender reputation with automated bounce detection and suppression. This workflow catches bounces as they happen rather than waiting for periodic cleanup.

## How It Works

| Condition | Action |
|-----------|--------|
| Hard bounce detected | Alert admin immediately, suppress contact |
| Suppression threshold reached (commonly 2-3 bounces) | Auto-suppress from marketing emails |
| Review threshold reached (commonly 3-5 bounces) | Flag for weekly manual review (delete vs. recover) |

## Prerequisites

- HubSpot Marketing Professional or Enterprise plan
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope (for the API path)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- A custom contact property for review flagging (default: `email_health_flag` — the execute script creates it if missing)
- Admin email or Slack channel for hard bounce alerts

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/bounce-monitoring-workflow/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/bounce-monitoring-workflow/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/bounce-monitoring-workflow/scripts/after.py` |

`before.py` inventories existing workflows and checks name collisions. `execute.py` creates the `email_health_flag` property (if missing) and three bounce workflows via `POST /automation/v4/flows` — **always disabled, for review before enabling**. `after.py` verifies and reports enabled state.

## Building the Workflow: Two Options

### Option A: Create via the v4 Automation API (primary)

Instead of one workflow with three nested branches, the script decomposes the design into **three linear workflows** — the same coverage, cleanly expressible via the API and independently tunable:

1. **Hard Bounce Suppression**: enrollment = `hs_email_hard_bounce_reason_enum` is known. Actions: set `email_health_flag`, set marketing contact status to non-marketing.
2. **Suppress at N+ Bounces**: enrollment = `hs_email_bounce` >= suppression threshold (re-enrollment on). Action: set marketing contact status to non-marketing.
3. **Flag for Review at M+ Bounces**: enrollment = `hs_email_bounce` >= review threshold (re-enrollment on). Action: set `email_health_flag` (feeds `/review-bounced-contacts`).

Configure `SUPPRESSION_THRESHOLD` and `REVIEW_THRESHOLD` in `execute.py`, then:

```bash
uv run skills/bounce-monitoring-workflow/scripts/before.py
uv run skills/bounce-monitoring-workflow/scripts/execute.py
```

**Safety model:** all three workflows are created with `isEnabled: false`. During UI review: add the internal-notification actions (hard bounce alert, review alert — notification recipients are portal-specific), add "Set marketing contact status" manually if the script reported it had to omit it, then turn the workflows on.

### Option B: Manual UI Build (single workflow with nested branches)

Follow the step-by-step instructions in Stage 3 below — the original single-workflow design with three branch levels. Use this on portals where the Automation API is unavailable.

**Alternatives:** HubSpot Breeze AI can scaffold a similar workflow from a prompt, but it creates event-based (OR) triggers, cannot configure re-enrollment, and may flatten nested branches — verify everything it builds. The Claude Chrome extension can drive the workflow builder UI directly. Both are secondary options.

## Step-by-Step Build Instructions

### Stage 1: Plan

1. Choose your **suppression threshold** (commonly 2-3 bounces) and **review threshold** (commonly 3-5).
2. Decide who receives hard-bounce and review alerts.

### Stage 2: Before

1. Run `uv run skills/bounce-monitoring-workflow/scripts/before.py` to inventory workflows and check name collisions.
2. Identify your current bounce baseline — run a quick search for contacts where `hs_email_bounce` > 0 to understand the starting volume.
3. If building manually, create your bounce review property (checkbox or dropdown) — the API path creates it automatically.

### Stage 3: Execute

**Option A:** run `uv run skills/bounce-monitoring-workflow/scripts/execute.py`, then complete the UI review steps listed above.

**Option B — manual UI build:** build a single contact-based workflow with branching logic.

1. **Trigger:** `hs_email_bounce` is known (fires when bounce count updates)

2. **Branch 1: Hard bounce check**
   - Condition: `hs_email_hard_bounce_reason_enum` is known
   - **YES:**
     - Send internal notification: "Hard bounce: {email} — {hs_email_hard_bounce_reason_enum}"
     - Set `hs_marketable_status` to non-marketing (workflow action)
   - **NO:** Continue to Branch 2

3. **Branch 2: Bounce count >= your suppression threshold (commonly 2-3)**
   - Condition: `hs_email_bounce` is greater than or equal to your suppression threshold
   - **YES:**
     - Set `hs_marketable_status` to non-marketing (workflow action)
     - Continue to Branch 3
   - **NO:** No action (below threshold — monitor only)

4. **Branch 3: Bounce count >= your review threshold (commonly 3-5)**
   - Condition: `hs_email_bounce` is greater than or equal to your review threshold
   - **YES:**
     - Set your bounce review property = flagged
     - Send internal notification: "Contact {email} has [review threshold]+ bounces — review for deletion"
   - **NO:** No further action

5. **Settings:**
   - Re-enrollment: ON (contact should re-enter if bounce count increases)
   - Goal: None

6. **Turn on the workflow.**

### Stage 4: After

1. Run `uv run skills/bounce-monitoring-workflow/scripts/after.py` (API path) to confirm the workflows exist and report enabled state.
2. Check workflow history after the first week of email sends.
3. Review the flagged contacts list weekly — decide for each contact:
   - **Delete** if the email is clearly invalid (typo domain, defunct company)
   - **Attempt recovery** if the domain is valid (could be a temporary mailbox issue)
4. Monitor overall bounce rate in HubSpot email health dashboard.

## Rollback

1. Turn off the workflow.
2. Contacts already suppressed remain non-marketing. To reverse:
   - Filter contacts suppressed by this workflow (check your bounce review property or workflow history)
   - Manually set back to marketing contacts in the UI
3. Clear your bounce review property values in bulk if needed.

## Weekly Review Process

For contacts flagged at your review threshold:

1. Export the flagged contacts list.
2. For each contact, check:
   - Is the email domain still active? (Quick MX record check)
   - Is this a known customer or high-value contact?
   - Was the bounce recent or historical?
3. **Delete** contacts with invalid domains or clearly fake emails.
4. **Keep suppressed** contacts with valid domains but repeated soft bounces.
5. Clear the bounce review property after review.

---

<!-- skill: engagement-suppression-workflow | category: automation-workflows | scripts: before,execute,after -->

---
name: engagement-suppression-workflow
description: "Build a two-tier sunset workflow that re-engages dormant contacts before suppressing them. Tier 1 triggers a re-engagement campaign after a configurable inactivity window. Tier 2 suppresses contacts that fail to re-engage within a configurable re-engagement window."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: automation-workflows
---

# Engagement-Based Suppression Workflow

Build a two-tier sunset system that protects email deliverability while giving disengaged contacts a fair chance to re-engage before suppression.

## Why Two Tiers Matter

Suppressing contacts immediately after inactivity is aggressive and loses potential re-activations. A two-tier approach:
- **Tier 1** (inactive for your sunset window — typically 120-270 days): Triggers a re-engagement campaign — a last chance to interact.
- **Tier 2** (your re-engagement window after Tier 1 — typically 21-45 days — with still no engagement): Suppresses the contact from marketing emails.

This preserves deliverability scores while maximizing the recoverable audience.

## Prerequisites

- HubSpot Marketing Professional or Enterprise plan
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope (for the API path)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- A re-engagement email campaign or sequence ready to send
- A custom dropdown property to track suppression status (default: `engagement_flag` — the execute script creates it if missing)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/engagement-suppression-workflow/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/engagement-suppression-workflow/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/engagement-suppression-workflow/scripts/after.py` |

`before.py` inventories existing workflows and checks for name collisions. `execute.py` creates the `engagement_flag` property (if missing) and both sunset workflows via `POST /automation/v4/flows` — **always disabled, for review in the UI before enabling**. `after.py` verifies they exist and reports enabled state.

## Workflow Design

```
TRIGGER: Last engagement date > [sunset window] ago
         AND email is known
         AND not globally unsubscribed
         AND [suppression status property] is unknown
              │
              ▼
     ┌────────────────────┐
     │ Set [status prop]   │
     │ = "re-engagement    │
     │    sent"            │
     └────────┬───────────┘
              │
              ▼
     ┌────────────────────┐
     │ Enroll in           │
     │ re-engagement       │
     │ campaign/sequence   │
     └────────┬───────────┘
              │
              ▼
     ┌────────────────────┐
     │ Delay: [re-engage    │
     │  window] days       │
     └────────┬───────────┘
              │
              ▼
     ┌────────────────────┐
     │ IF/THEN BRANCH:     │
     │ Any engagement in   │
     │ re-engage window?   │
     ├──────────┬─────────┘
     │ YES      │ NO
     │          │
     ▼          ▼
   Clear     Set [status prop]
   status    = "suppressed"
   prop      + set non-marketing
```

## Building the Workflow: Two Options

### Option A: Create via the v4 Automation API (primary)

The stable v4 Automation API supports creating workflows programmatically. Instead of one workflow with an if/then branch, the script decomposes the design into **two linear workflows** — which the API expresses cleanly and which are easier to reason about:

1. **Tier 1 — Flag for Re-engagement**: enrollment = last open older than the sunset window (or never) AND email known AND not globally unsubscribed AND flag unknown. Action: set `engagement_flag` = "re-engagement sent".
2. **Tier 2 — Suppress Non-responders**: enrollment = flag = "re-engagement sent" AND still no open/click after sunset + re-engagement windows. Actions: set flag = "suppressed", set marketing contact status to non-marketing.

Run the scripts (configure `SUNSET_DAYS`, `REENGAGE_DAYS`, `FLAG_PROPERTY` in `execute.py` first):

```bash
uv run skills/engagement-suppression-workflow/scripts/before.py
uv run skills/engagement-suppression-workflow/scripts/execute.py
```

**Safety model:** both workflows are created with `isEnabled: false`. Review them in Automation > Workflows, then:
- Add the "enroll in re-engagement sequence/email" action to Tier 1 (email/sequence IDs are portal-specific).
- If the script reported it could not include the set-marketing-status action, add "Set marketing contact status" to Tier 2 in the UI (one click).
- Turn both on, choosing whether to enroll existing contacts.

**Payload notes:** the scripts use documented v4 action types (`0-5` set property; `0-31` set marketing contact status, with automatic fallback if your portal rejects its undocumented field shape). If a filter shape is rejected, HubSpot's own recommendation applies: build the workflow once in the UI, `GET /automation/v4/flows/{flowId}`, and adapt the script to the exact shapes your portal returns.

### Option B: Manual UI Build (single workflow with branch)

Follow the step-by-step instructions in Stage 3 below. This builds the original single-workflow design with the if/then re-engagement branch, and works on portals where the Automation API is unavailable.

**Alternatives:** HubSpot Breeze AI can scaffold a similar workflow from a prompt, but it creates event-based (OR) triggers instead of filter-based (AND) triggers and cannot configure re-enrollment or "is unknown" conditions — every Breeze-built trigger needs manual verification. The Claude Chrome extension can drive the workflow builder UI directly. Both are secondary to Options A and B.

## Step-by-Step Build Instructions

### Stage 1: Plan

1. Choose your **sunset window** (typically 120-270 days; default 180) and **re-engagement window** (21-45 days; default 30).
2. **Define "engagement"** for the Tier 2 check. Recommended: email open OR email click within the re-engagement window.
3. **Create or identify a re-engagement email/sequence.** A simple 1-2 email series asking "Still interested?" with a clear CTA works well.

### Stage 2: Before

Run `uv run skills/engagement-suppression-workflow/scripts/before.py` — it inventories all workflows, flags name collisions with the two planned SUNSET workflows, and writes the inventory CSV baseline.

If building manually, also create your suppression status property (dropdown: "re-engagement sent", "suppressed") if it does not exist — the API path creates it automatically.

### Stage 3: Execute

**Option A:** run `uv run skills/engagement-suppression-workflow/scripts/execute.py`, then complete the UI review steps listed above.

**Option B — manual UI build:**

1. **Set enrollment trigger:**
   - `hs_email_last_open_date` is more than your sunset window (typically 120-270 days) ago OR is unknown
   - AND `email` is known
   - AND `hs_email_optout` is not true
   - AND your suppression status property is unknown

2. **Action: Set contact property**
   - Your suppression status property = "re-engagement sent"

3. **Action: Enroll in re-engagement sequence** (or send re-engagement email)

4. **Delay: your re-engagement window (typically 21-45 days)**

5. **If/then branch:**
   - Condition: `hs_email_last_open_date` is less than [re-engagement window] days ago OR `hs_email_last_click_date` is less than [re-engagement window] days ago
   - **YES (re-engaged):** Set your suppression status property to blank/unknown (clears flag, contact returns to normal)
   - **NO (still disengaged):** Set your suppression status property = "suppressed" and set `hs_marketable_status` to non-marketing contact via the "Set marketing contact status" workflow action. (The property itself is read-only via direct API writes — a workflow action is the mechanism, whether the workflow is built in the UI or created via the v4 API.)

6. **Settings:**
   - Re-enrollment: OFF
   - Goal: Contact opens or clicks any email (optional — exits workflow early)

7. **Turn on the workflow.**

### Stage 4: After

1. Run `uv run skills/engagement-suppression-workflow/scripts/after.py` (API path) to confirm both workflows exist and report their enabled state.
2. Spot-check 10-20 contacts that entered the workflow. Confirm:
   - Re-engagement email was sent
   - After the re-engagement window, disengaged contacts were suppressed
   - Re-engaged contacts had their suppression status property cleared (Option B) or never entered Tier 2 (Option A)
3. Monitor deliverability metrics weekly for the first month.
4. Track how many contacts re-engage vs. get suppressed — adjust the sunset window if needed.

## Rollback

1. Turn off the workflow.
2. To reverse suppressions: filter contacts where your suppression status property = "suppressed" and manually set them back to marketing contacts in the UI.
3. Clear the suppression status property values in bulk if needed.

## Tuning

- **Shorten the sunset window** (e.g., 90-120 days) for aggressive deliverability improvement.
- **Lengthen the re-engagement window** (e.g., 45-60 days) if your email cadence is low.
- **Exclude recent customers** — add a filter to skip contacts with lifecycle stage = Customer or with a closed-won deal in the last 12 months.

---

<!-- skill: lifecycle-progression-workflow | category: automation-workflows | scripts: before,execute,after -->

---
name: lifecycle-progression-workflow
description: "Build workflows to automate contact progression through the sales funnel: Lead to MQL to SQL to Opportunity to Customer. Each transition is triggered by a specific event (score threshold, meeting booked, deal created, deal won)."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: automation-workflows
---

# Lifecycle Stage Progression Workflow

Automate the contact journey through the sales funnel with four progression workflows, each triggered by a specific business event.

## Progression Paths

| From | To | Trigger |
|------|----|---------|
| Lead | MQL | Lead score exceeds threshold |
| MQL | SQL | Meeting booked |
| SQL | Opportunity | Deal created and associated |
| Opportunity | Customer | Deal marked as closed-won |

## Prerequisites

- HubSpot Marketing Professional or Enterprise plan
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope (for the API path)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Lead scoring model configured (run `/build-lead-scoring` first) — you need the internal name of your score property
- Deal pipeline set up with a "Closed Won" stage
- Meeting tool or integration configured (for SQL transition)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/lifecycle-progression-workflow/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/lifecycle-progression-workflow/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/lifecycle-progression-workflow/scripts/after.py` |

`before.py` inventories existing workflows and checks name collisions. `execute.py` creates all four progression workflows via `POST /automation/v4/flows` — **always disabled, for review before enabling**. `after.py` verifies and reports enabled state.

## Building the Workflow: Two Options

### Option A: Create via the v4 Automation API (primary)

All four progression workflows are linear (filter-based AND trigger → set lifecycle stage), which the v4 API expresses directly. The script encodes:

| Workflow | Enrollment (AND) | Action |
|----------|------------------|--------|
| Lead → MQL | score property >= threshold + stage = Lead | set stage = MQL |
| MQL → SQL | `engagements_last_meeting_booked` is known + stage = MQL | set stage = SQL |
| SQL → Opportunity | `num_associated_deals` >= 1 + stage = SQL | set stage = Opportunity |
| Opportunity → Customer | `hs_current_customer` = true + stage = Opportunity | set stage = Customer |

The last workflow uses `hs_current_customer` — the read-only system property HubSpot introduced in June 2026 that marks contacts belonging to a current customer — as a cleaner signal than inspecting associated deal stages.

**Before running:** set `LEAD_SCORE_PROPERTY` in `execute.py` to your score property's internal name. With HubSpot's post-2025 lead scoring tool, each score you create generates its own property — find the internal name under Settings > Properties (it is not the retired `hubspotscore`).

```bash
uv run skills/lifecycle-progression-workflow/scripts/before.py
uv run skills/lifecycle-progression-workflow/scripts/execute.py
```

**Safety model:** all four workflows are created with `isEnabled: false`. Review the triggers in the UI, add the optional internal-notification actions (marketing team, sales owner, CS/onboarding — recipients are portal-specific), then enable them one at a time, watching the first enrollments.

### Option B: Manual UI Build

Follow the step-by-step instructions in Stage 3 below. Use this on portals where the Automation API is unavailable, or when you prefer to configure the meeting/deal triggers with portal-specific event conditions instead of the property-based equivalents the script uses.

**Alternatives:** HubSpot Breeze AI can scaffold these workflows from prompts, but it creates event-based (OR) triggers where AND logic between the event and the current stage is required, and cannot configure re-enrollment — verify everything it builds. The Claude Chrome extension can drive the workflow builder UI directly. Both are secondary options.

## Step-by-Step Build Instructions

### Stage 1: Plan

1. Define your MQL score threshold (typically 40-60 on a 0-100 scale). Adjust after 30-60 days of observation.
2. Identify your lead score property's internal name (post-2025 scoring tool properties are per-score; see `/build-lead-scoring`).
3. Confirm your deal pipeline stages include a clear "Closed Won" equivalent.

### Stage 2: Before

1. Run `uv run skills/lifecycle-progression-workflow/scripts/before.py` to inventory workflows and check name collisions.
2. Document current lifecycle stage distribution (run the audit or check the property breakdown) so you can measure the impact.

### Stage 3: Execute

**Option A:** run `uv run skills/lifecycle-progression-workflow/scripts/execute.py`, then complete the UI review steps listed above.

**Option B — manual UI build:** build each as a separate contact-based workflow.

#### Workflow 1: Lead to MQL

1. **Trigger:** Your lead score property is greater than or equal to [threshold] AND lifecycle stage is "Lead"
2. **Action:** Set lifecycle stage to "Marketing Qualified Lead"
3. **Action (optional):** Send internal notification to marketing team
4. **Re-enrollment:** OFF

#### Workflow 2: MQL to SQL

1. **Trigger:** Meeting booked (use "Meeting activity date" is known, or "Number of meetings booked" is greater than 0) AND lifecycle stage is "Marketing Qualified Lead"
2. **Action:** Set lifecycle stage to "Sales Qualified Lead"
3. **Action (optional):** Send internal notification to sales owner
4. **Re-enrollment:** OFF

#### Workflow 3: SQL to Opportunity

1. **Trigger:** Associated deal is created (use "Number of associated deals" is greater than 0) AND lifecycle stage is "Sales Qualified Lead"
2. **Action:** Set lifecycle stage to "Opportunity"
3. **Re-enrollment:** OFF

#### Workflow 4: Opportunity to Customer

1. **Trigger:** Associated deal stage equals "Closed Won" AND lifecycle stage is "Opportunity"
2. **Action:** Set lifecycle stage to "Customer"
3. **Action (optional):** Send internal notification to CS/onboarding team
4. **Re-enrollment:** OFF

#### Workflow Settings (all four)

- Re-enrollment: OFF (lifecycle should only progress forward)
- Suppression list: None needed — the lifecycle stage condition prevents backwards movement
- Time zone: Not applicable

### Stage 4: After

0. Run `uv run skills/lifecycle-progression-workflow/scripts/after.py` (API path) to confirm all four workflows exist and report enabled state.
1. Test each workflow with a test contact:
   - Manually adjust score/create meeting/create deal/close deal and confirm progression.
2. Verify that workflows do not conflict — a contact should not be enrolled in two progression workflows simultaneously.
3. Check that lifecycle stages only move forward (HubSpot enforces this by default, but verify).
4. After one week, review the workflow history for each. Check for:
   - Contacts stuck at a stage despite meeting criteria
   - Unexpected enrollment volumes

## Rollback

1. Turn off any or all four workflows.
2. Lifecycle stages already set remain — HubSpot does not allow backward movement without manual override or a dedicated reset workflow.
3. If stages were set incorrectly, create a temporary workflow or use the API to reset affected contacts.

## Notes

- **Backward movement:** HubSpot prevents lifecycle stage from going backward by default. If a deal is lost and the contact should return to MQL, you need a separate "regression" workflow that explicitly sets the stage.
- **Multiple deals:** If a contact has multiple deals, the Opportunity-to-Customer workflow fires when any associated deal is closed-won. This is usually the desired behavior.
- **Score decay:** HubSpot's post-2025 scoring tool supports engagement decay, so a contact's score may drop below the MQL threshold after promotion. This is fine — the lifecycle stage is already set and will not regress.

---

<!-- skill: new-contact-hygiene-workflow | category: automation-workflows | scripts: before,execute,after -->

---
name: new-contact-hygiene-workflow
description: "Build a HubSpot workflow that auto-enriches and stages new contacts upon creation. Sets lifecycle stage via the v4 Automation API, copies company name and industry from the associated company (UI step), and branches on completeness."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: automation-workflows
---

# New Contact Hygiene Workflow

Build a workflow that automatically enriches every new contact at creation time. This ensures contacts enter the database with a lifecycle stage, company name, and industry before any human touches them.

## What Is and Isn't Scriptable

The v4 Automation API (stable) creates workflows programmatically, including "is unknown" enrollment conditions via `LIST_BASED` filter criteria. Two pieces of this particular design still belong in the UI:

1. **Copy from associated object** actions (copy company name/industry from the associated company) have no documented v4 action fields. Add them in the UI during review — or build the action once in the UI, `GET /automation/v4/flows/{flowId}`, and extend the script with the exact field shapes your portal returns.
2. **The notification branch** (alert admin when a contact still has no company after the delay) depends on portal-specific recipients.

So the split is: the script creates the default-lifecycle-stage workflow via API; the enrichment copy actions and the notification branch are added in the UI. If you already run `/enrich-company-name` and `/enrich-industry`, their dedicated workflows cover the copy actions and this skill's API-created workflow completes the intake picture.

## Prerequisites

- HubSpot Marketing Professional or Enterprise plan
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope (for the API path)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Workflow creation permissions in HubSpot
- Company association enrichment completed (run `/enrich-company-name` and `/enrich-industry` first for existing contacts)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Before | [`scripts/before.py`](./scripts/before.py) | `uv run skills/new-contact-hygiene-workflow/scripts/before.py` |
| Execute | [`scripts/execute.py`](./scripts/execute.py) | `uv run skills/new-contact-hygiene-workflow/scripts/execute.py` |
| After | [`scripts/after.py`](./scripts/after.py) | `uv run skills/new-contact-hygiene-workflow/scripts/after.py` |

`execute.py` creates one workflow — "HYGIENE: Default Lifecycle Stage for New Contacts" (enrollment: create date known AND lifecycle stage unknown; action: set stage to Lead; re-enrollment on) — **disabled, for review before enabling**. Extend it in the UI with the copy actions and notification branch per the design below.

## Building the Workflow: Two Options

**Option A (primary):** run the scripts, then extend the created workflow in the UI with the copy-from-associated-company actions, the delay, and the no-company notification branch (Stage 3, steps 3-6).

**Option B:** build the whole workflow manually in the UI (all of Stage 3).

**Alternatives:** HubSpot Breeze AI provides minimal value for this workflow — it creates event-based (OR) triggers, and cannot create "is unknown" conditions, copy-from-association actions, or re-enrollment rules. The Claude Chrome extension can drive the workflow builder UI directly and handles those cases. Both are secondary options.

## Workflow Design

```
TRIGGER: Contact create date is known
         (fires for every new contact)
           │
           ▼
    ┌─────────────────────────┐
    │ Set lifecycle stage      │
    │ = "Lead" (if empty)      │
    └────────────┬────────────┘
                 │
                 ▼
    ┌─────────────────────────┐
    │ Copy company name from   │
    │ associated company       │
    └────────────┬────────────┘
                 │
                 ▼
    ┌─────────────────────────┐
    │ Copy industry from       │
    │ associated company       │
    └────────────┬────────────┘
                 │
                 ▼
    ┌─────────────────────────┐
    │ Delay: short wait         │
    │ (3-10 min, rec: 5)      │
    └────────────┬────────────┘
                 │
                 ▼
    ┌─────────────────────────┐
    │ IF/THEN BRANCH:          │
    │ Company name is unknown? │
    ├──────────┬──────────────┘
    │ YES      │ NO
    │          │
    ▼          ▼
  Retry      Continue
  copy       (enriched)
  + notify
  admin
```

## Step-by-Step Build Instructions

### Stage 1: Plan

1. Confirm the default stage for new contacts (Lead, unless your funnel differs).
2. Decide who receives the "no company association" notification.

### Stage 2: Before

1. Confirm company enrichment processes have run for existing data.
2. Run `uv run skills/new-contact-hygiene-workflow/scripts/before.py` to inventory workflows and check name collisions.

### Stage 3: Execute

**Option A:** run `uv run skills/new-contact-hygiene-workflow/scripts/execute.py`, then open the created workflow in the UI and continue from step 3 below (copy actions, delay, branch). **Option B:** open HubSpot > Automation > Workflows > Create workflow, select "Contact-based", start from scratch, and follow all steps.

1. **Set enrollment trigger:**
   - Property: "Create date" > "is known"
   - This enrolls every new contact automatically.

2. **Add action: Set property value**
   - Property: "Lifecycle stage"
   - Value: "Lead"
   - Condition: Only if lifecycle stage is unknown (use an if/then branch before this step, or rely on HubSpot's "only if empty" option if available in your plan).

3. **Add action: Copy property**
   - Source: Associated company > "Company name"
   - Target: Contact > "Company" property

4. **Add action: Copy property**
   - Source: Associated company > "Industry"
   - Target: Contact > "Industry" property

5. **Add delay: a short delay (3-10 minutes, recommended: 5)**
   - Purpose: Allow time for company associations to sync (especially for form submissions or integrations that create contacts before associating them). Adjust the duration based on how quickly your integrations typically create associations.

6. **Add if/then branch:**
   - Condition: Contact "Company" property is unknown
   - YES branch: Add internal notification to CRM admin — "New contact {firstname} {lastname} ({email}) has no company association after enrichment attempt."
   - NO branch: No further action needed (contact is enriched).

7. **Review settings:**
   - Re-enrollment: OFF (each contact should only go through this once)
   - Unenrollment: None needed
   - Time zone: Not applicable (no time-based actions beyond delay)

8. **Turn on the workflow.**

### Stage 4: After

0. Run `uv run skills/new-contact-hygiene-workflow/scripts/after.py` (API path) to confirm the workflow exists and report enabled state.
1. Create a test contact manually. Confirm:
   - Lifecycle stage is set to "Lead"
   - Company name copied from associated company
   - Industry copied from associated company
2. Create a test contact with no company association. Confirm:
   - Admin notification fires after the configured delay
3. Check workflow history for any errors in the first 24 hours.

## Rollback

1. Turn off the workflow in HubSpot > Automation > Workflows.
2. Contacts already enriched retain their values — no destructive changes to undo.
3. If lifecycle stages were set incorrectly, use the Search API to find contacts created after the workflow activation date and reset as needed.

## Edge Cases

- **Contacts created via import:** These fire the trigger. If imports include company name/industry, the copy action will overwrite with the associated company's values. Consider excluding imported contacts via a list filter.
- **Contacts without company associations:** The copy action silently fails. The branch handles notification.
- **Multiple associated companies:** HubSpot copies from the primary associated company only.

---

<!-- skill: workflows-as-code | category: automation-workflows | scripts: none -->

---
name: workflows-as-code
description: "Export all HubSpot workflows to versioned JSON files via the v4 Automation API, diff exports over time, and restore or recreate workflows from JSON. Treats workflow definitions like code: backed up, reviewable, and recoverable."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: automation-workflows
---

# Workflows as Code

Export every workflow in the portal to JSON files, keep the exports under version control, and restore workflows from JSON when something breaks. HubSpot has no recycle bin for workflows — a deleted or mangled workflow is gone unless you have its definition. This skill gives you that safety net, plus reviewable diffs of what changed between exports.

## Why This Matters

Workflows are production automation, but most portals treat them like disposable UI configuration: no backups, no change history, no review. One mis-click in the workflow editor can silently break lead routing or suppression for weeks. The v4 Automation API supports full read and create of workflow definitions, which makes a code-like lifecycle possible: export → version → diff → restore.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope (plus sensitive-data scopes if any flows touch sensitive properties)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- A place to keep exports — a git repository is ideal (`data/workflow-exports/` is gitignored here by default because exports may reference internal names; move them to a private repo if you want history)

## Scripts

| Stage | Script | Run with |
|-------|--------|----------|
| Export | [`scripts/export.py`](./scripts/export.py) | `uv run skills/workflows-as-code/scripts/export.py` |
| Restore | [`scripts/restore.py`](./scripts/restore.py) | `uv run skills/workflows-as-code/scripts/restore.py <export-file.json>` |

`export.py` lists all flows via `GET /automation/v4/flows`, fetches each full definition via the batch read endpoint (`POST /automation/v4/flows/batch/read`, falling back to per-flow GETs), and writes one JSON file per workflow plus a manifest. `restore.py` recreates a workflow from an export file via `POST /automation/v4/flows` — **always disabled**, under a "(restored)" name, for review before enabling.

## Execution Pattern

### Stage 1: Plan

1. Decide export cadence: before any workflow cleanup (mandatory — see `/cleanup-workflows`), after building new workflows, and on a schedule (monthly pairs well with `/quarterly-database-cleanup`).
2. Decide where exports live long-term (private git repo recommended).

### Stage 2: Before

Nothing to prepare — the export is read-only. Note the current workflow count from Automation > Workflows for comparison.

### Stage 3: Execute — Export

```bash
uv run skills/workflows-as-code/scripts/export.py
```

Output layout:

```
data/workflow-exports/<YYYY-MM-DD>/
├── manifest.csv              # flowId, name, type, isEnabled, revisionId, file
├── flow-<flowId>.json        # one full definition per workflow
└── ...
```

To diff two exports:

```bash
diff -u data/workflow-exports/2026-06-01/flow-12345.json \
        data/workflow-exports/2026-07-01/flow-12345.json
```

(Timestamps and revision IDs will differ; meaningful changes show up in `actions` and `enrollmentCriteria`.)

### Stage 3 (alternative): Execute — Restore

To recreate a deleted or broken workflow from an export:

```bash
uv run skills/workflows-as-code/scripts/restore.py data/workflow-exports/2026-06-01/flow-12345.json
```

The script strips server-assigned fields (`id`, `revisionId`, timestamps), renames the flow with a "(restored)" suffix to avoid name collisions, and creates it **disabled**. Review it in the UI, verify enrollment criteria and actions against the original, then rename and enable.

### Stage 4: After

1. Export: verify the manifest row count matches the portal's workflow count and spot-open one JSON file.
2. Restore: open the restored workflow in Automation > Workflows, compare against the export, then enable.

## Rollback

- Export is read-only — nothing to roll back.
- A restored workflow you do not want: it was created disabled; just delete it.

## Technical Gotchas

1. **Updating in place requires the current `revisionId`.** `PUT /automation/v4/{flowId}` must include the flow's latest `revisionId` and `type` — a stale revision is rejected. The restore script sidesteps this by creating a new flow instead of updating.
2. **Some actions round-trip imperfectly.** Actions whose field shapes are undocumented (e.g., copy-from-associated-object, notification recipients) export fine but may be rejected on re-create in a different portal. The restore script reports per-action errors; add rejected actions in the UI.
3. **v3 workflow IDs are not v4 flow IDs.** If you have old tooling built on `/automation/v3/workflows`, the v4 API provides workflowId → flowId mapping endpoints for migration. New tooling should use v4 exclusively — v3 is legacy.
4. **Sensitive-data scopes.** Flows that read or set sensitive properties require the corresponding sensitive-data scopes on your private app, or reads will fail.
5. **Exports may contain internal data.** Workflow names, property names, list IDs, and notification text are all in the JSON. Treat exports like configuration secrets — keep them in a private repo.

---

<!-- skill: backfill-geo-data | category: ongoing-maintenance | scripts: none -->

---
name: backfill-geo-data
description: "Enrich missing geographic data (country, state, city) on contacts and companies using HubSpot workflows, external data providers, or IP-based geolocation."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Backfill Geographic Data

Fill in missing country, state, and city values on contacts and companies. Geographic data enables territory assignment, regional reporting, and compliance (GDPR, state privacy laws).

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with contact and company read/write scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Standardized geo values already in place (run `/standardize-geo-values` first)

## Enrichment Methods

### Method 1: HubSpot Workflow Enrichment (Simplest)

Use HubSpot's built-in Operations Hub data quality tools or Breeze Intelligence (if available on your plan) to auto-fill geographic fields.

1. Create a workflow triggered by: country is unknown AND email is known
2. Use the "Enrich contact" action (Operations Hub Professional+) or Breeze Intelligence enrichment
3. If enrichment fills country/state, the workflow completes
4. If enrichment fails, branch to flag for manual review

### Method 2: Company Domain Lookup (API-based)

For contacts with a company domain but no geo data, look up the company's geographic information:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

# Find contacts missing country but with company association
resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "country", "operator": "NOT_HAS_PROPERTY"},
        {"propertyName": "associatedcompanyid", "operator": "HAS_PROPERTY"},
    ]}],
    "properties": ["email", "associatedcompanyid"],
    "limit": 100,
})
resp.raise_for_status()
```

Copy country/state/city from the associated company to the contact (same pattern as `/enrich-company-name`).

### Method 3: External Data Provider

Use `/waterfall-enrich-contacts` — it provides pluggable provider adapters (FullEnrich by default; Apollo, Hunter, Dropcontact included, or bring your own), per-run cost caps, no-overwrite safety, and a CSV audit trail. Exhaust Methods 1-2 first: external lookups cost credits per contact, internal data is free.

## Step-by-Step Instructions

### Stage 1: Plan

1. Choose the enrichment method (see above) based on volume, plan tier, and budget — confirm with the user.
2. Confirm the no-overwrite rule: enrichment must only fill empty fields.

### Stage 2: Before — Assess the Gap

1. Count contacts missing country, state, and city.
2. Segment by source — which lead sources tend to have missing geo data?
3. Export a CSV baseline of the affected records before changing anything.

### Stage 3: Execute — Run Enrichment

1. Apply the chosen method (or combine methods for maximum coverage).
2. Process in batches of 100 to respect rate limits.
3. Validate enriched values against the standardized geo format from `/standardize-geo-values`.

### Stage 4: After — Verify

1. Re-count contacts missing geographic fields. Calculate improvement percentage.
2. Spot-check 20-30 enriched contacts for accuracy.
3. Set up the new-contact hygiene workflow to prevent future gaps.

## Rollback

- If enrichment data is inaccurate, filter contacts updated by the enrichment process (use `hs_lastmodifieddate` range) and clear the geo fields.
- Keep a backup export of the original data before running enrichment.

## Tips

- IP-based geolocation (from form submissions) is already captured by HubSpot in `ip_city`, `ip_state`, `ip_country`. Copy these to the standard fields if the standard fields are empty.
- Do not overwrite manually-entered geo data with enrichment data — always check "if empty" before writing.

---

<!-- skill: cleanup-dashboards | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-dashboards
description: "Audit and consolidate HubSpot reporting dashboards. Identifies unused, duplicate, or outdated dashboards. Must be performed manually — no dashboard API is available."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Dashboards

Audit HubSpot dashboards to remove clutter and consolidate reporting. Too many dashboards means nobody uses any of them effectively.

## Important Limitation

HubSpot does not provide a Dashboard API. This entire process must be performed manually in the HubSpot UI under Reports > Dashboards.

## Prerequisites

- HubSpot portal access with dashboard management permissions
- Input from team members on which dashboards they actively use

## Step-by-Step Instructions

### Stage 1: Before — Inventory All Dashboards

1. Navigate to Reports > Dashboards in HubSpot.
2. Create a spreadsheet listing every dashboard:
   - Name, owner/creator, number of reports, last viewed date (if visible), purpose

### Stage 2: Execute — Identify Candidates for Removal

Flag dashboards matching any of these criteria:

1. **Not viewed** in 90+ days (check with the owner first)
2. **Duplicate** dashboards covering the same metrics
3. **Test dashboards** (names containing "test", "draft", "copy of")
4. **Personal dashboards** belonging to departed employees
5. **Default dashboards** that were never customized

Consolidation targets:
- Merge dashboards with overlapping report widgets into a single comprehensive dashboard.
- Aim for 5-10 core dashboards maximum (e.g., Marketing Overview, Sales Pipeline, Email Health, Data Quality, Executive Summary).

### Stage 3: After — Clean Up and Reorganize

1. Delete confirmed unused dashboards.
2. Rename remaining dashboards with a clear naming convention (e.g., `[Team] - Purpose`).
3. Set appropriate sharing/visibility for each dashboard.
4. Communicate changes to the team — share links to the consolidated dashboards.

### Stage 4: Rollback

- Deleted dashboards cannot be restored.
- Before deleting, screenshot each dashboard or note which reports it contained.
- Individual reports within a dashboard are not deleted when the dashboard is removed — they remain available for re-use.

## Tips

- Assign a dashboard owner for each core dashboard — someone responsible for keeping it current.
- Review dashboards quarterly as part of the database cleanup routine.
- If a report on a dashboard shows stale or broken data, fix the underlying report rather than creating a new dashboard.

---

<!-- skill: cleanup-deals | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-deals
description: "Standardize deal pipelines, remove test deals, and address deals with missing amounts or close dates. Coordinates with Salesforce sync if applicable."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Deals

Standardize deal data to make pipeline reporting accurate. Test deals, missing amounts, and stale opportunities distort forecasts and pipeline metrics.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.objects.deals.read` and `crm.objects.deals.write` scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Knowledge of which deal pipelines are active and which are synced from Salesforce

## Important: Salesforce Sync Considerations

If deals are synced from Salesforce:
- Do NOT delete or modify synced deals without coordinating with the Salesforce admin.
- Changes in HubSpot may sync back to Salesforce and cause data loss.
- Identify synced deals by checking for the `hs_salesforceopportunityid` property.

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. Which pipelines are in scope, and which are Salesforce-synced (hands off without coordination)?
2. The staleness cutoff for closing abandoned deals (default: 90 days without activity).

### Stage 2: Before

Pull deal metrics via the CRM Search API:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

def deal_count(prop, operator):
    resp = requests.post(f"{BASE}/crm/v3/objects/deals/search", headers=HEADERS, json={
        "filterGroups": [{"filters": [{"propertyName": prop, "operator": operator}]}],
        "limit": 1,
    })
    resp.raise_for_status()
    return resp.json()["total"]

no_amount = deal_count("amount", "NOT_HAS_PROPERTY")
no_close = deal_count("closedate", "NOT_HAS_PROPERTY")
```

Record: total deals, deals per pipeline stage, deals missing amount, deals missing close date, stale deals (open with no activity in 60+ days).

### Stage 3: Execute

1. **Delete test deals** — search for deals with names containing "test", "demo", "sample", or with amount = $0 and no associated contacts.
2. **Address missing amounts** — export deals without `amount` and work with sales to fill in values or mark as lost.
3. **Close stale deals** — deals open with no activity in 90+ days should be reviewed with the deal owner. Set to "Closed Lost" if abandoned.
4. **Standardize pipeline stages** — ensure all pipelines have consistent stage names and probability percentages.
5. **Remove unused pipelines** — if a pipeline has zero active deals and is not in use, archive or delete it.

### Stage 4: After

1. Re-run the deal audit queries. Confirm:
   - Test deals removed
   - Missing amount count decreased
   - Stale deal count decreased
2. Check pipeline reports for accuracy.

## Rollback

- Deleted deals can be restored from HubSpot's recycling bin within 90 days.
- Stage changes and property updates can be reverted manually but there is no bulk undo.
- For Salesforce-synced deals, check the Salesforce recycle bin as well.

## Tips

- Establish a deal hygiene rule: deals without activity for 60 days get an automated reminder to the owner (build a simple workflow).
- Require `amount` and `closedate` as mandatory deal properties to prevent future gaps.

---

<!-- skill: cleanup-forms | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-forms
description: "Audit and remove unused, test, or deprecated forms from HubSpot. Identifies forms with zero submissions, forms not embedded on any page, and test forms left over from development."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Forms

Audit HubSpot forms to remove unused and test forms. Stale forms clutter the forms dashboard and can cause confusion when building workflows or reports.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `forms` scope
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Note: The Forms API may return 403 on some plan tiers. If so, perform the audit manually in the HubSpot UI under Marketing > Forms.

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. How aggressive to be: delete outright, or prefix with "[DEPRECATED]" first and delete next quarter?
2. Any forms that must never be touched (compliance, legal, active campaigns)?

### Stage 2: Before

Inventory all forms via the Marketing Forms API (v3):

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

forms, after = [], None
while True:
    params = {"limit": 100}
    if after:
        params["after"] = after
    resp = requests.get(f"{BASE}/marketing/v3/forms", headers=HEADERS, params=params)
    resp.raise_for_status()
    data = resp.json()
    forms.extend(data.get("results", []))
    after = data.get("paging", {}).get("next", {}).get("after")
    if not after:
        break
```

For each form, record: form ID, name, type, submission count, created date, last submission date.

### Stage 3: Execute

Flag forms matching any of these criteria:

1. **Zero submissions** and created more than 30 days ago
2. **No recent submissions** (last submission 6+ months ago) and not embedded on an active page
3. **Test forms** (names containing "test", "temp", "draft", "copy of")
4. **Deprecated forms** replaced by newer versions

Before deleting, check:
- Is the form referenced in any workflow enrollment trigger?
- Is the form embedded on any live landing page or website page?
- Is the form used in any pop-up or slide-in CTA?

Present the candidate list to the user and wait for explicit confirmation, then delete confirmed unused forms via the API (`DELETE /marketing/v3/forms/{formId}`) or UI.

### Stage 4: After

1. Re-run the inventory and confirm the deleted forms are gone.
2. Document what was deleted in a cleanup log.
3. If a form with submissions is deleted, the submission data is retained on the contact records — but the form definition is gone.

## Rollback

- Deleted forms cannot be restored in HubSpot.
- Before deleting a form with any submissions, export the form definition (field names, settings) so it can be recreated.
- Contact records retain their form submission history regardless of form deletion.

## Tips

- Establish a naming convention: `[TEAM] - Purpose - Version` (e.g., `[Marketing] - Webinar Registration - v2`).
- Prefix deprecated forms with "[DEPRECATED]" instead of deleting immediately — delete after one quarter of no usage.

---

<!-- skill: cleanup-lead-owners | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-lead-owners
description: "Remove non-employee users from HubSpot and reassign their orphaned contacts, companies, and deals. Pairs with the assign-unowned-contacts skill for comprehensive ownership cleanup."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Lead Owners

Remove departed employees from HubSpot and reassign their CRM records. Orphaned records with no active owner fall through the cracks.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.objects.owners.read` and contact/company/deal write scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- A list of current employees (to compare against HubSpot users)
- A default owner or round-robin assignment rule for orphaned records

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. Who receives reassigned records — a single default owner, or territory/round-robin rules?
2. Which flagged users should be deactivated vs merely stripped of records?

### Stage 2: Before

Identify non-employee owners via the Owners API:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

active = requests.get(f"{BASE}/crm/v3/owners", headers=HEADERS,
                      params={"limit": 100, "archived": "false"}).json()["results"]
deactivated = requests.get(f"{BASE}/crm/v3/owners", headers=HEADERS,
                           params={"limit": 100, "archived": "true"}).json()["results"]
```

Cross-reference with your current employee list. Flag:
- Deactivated HubSpot users who still own records
- Active HubSpot users who are no longer employees
- Contractors or vendors who should not own records

For each flagged owner, count how many contacts, companies, and deals they own.

### Stage 3: Execute

1. **Reassign records** owned by non-employees:
   - Use the batch update API to reassign contacts to the appropriate active owner
   - Apply round-robin or territory-based rules if no specific owner is obvious
   - Reassign companies and deals associated with the same contacts

2. **Deactivate users** who are no longer employees (requires Super Admin in HubSpot Settings > Users & Teams).

3. **Run `/assign-unowned-contacts`** after reassignment to catch any records that ended up without an owner.

### Stage 4: After

1. Search for contacts where `hubspot_owner_id` matches any deactivated owner ID — count should be zero.
2. Confirm all reassigned contacts have an active owner.
3. Check that no workflows broke due to owner changes (some workflows may filter by specific owners).

## Rollback

- Owner reassignments can be reversed by batch-updating the `hubspot_owner_id` back to the original value.
- Keep a log of original owner assignments before making changes.
- Deactivated users can be reactivated in HubSpot Settings if needed.

## Tips

- Run this whenever an employee leaves the company — do not wait for quarterly cleanup.
- Set up an offboarding checklist that includes HubSpot record reassignment.
- Pairs with `/assign-unowned-contacts` for comprehensive ownership hygiene.

---

<!-- skill: cleanup-lists | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-lists
description: "Audit and remove unused, empty, or duplicate list definitions from HubSpot. Identifies lists with zero members, lists not used by any workflow or email, and overlapping list criteria."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Lists

Audit HubSpot lists to remove clutter. Unused lists slow down the UI, confuse team members, and can mask the lists that actually matter.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.lists.read` (and `crm.lists.write` for deletion)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Note: Lists API access may return 403 on some plan tiers. If so, perform the audit manually in the UI.

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. Delete outright, or prefix with "[ARCHIVE]" first and delete next quarter?
2. Any lists that must never be touched (suppression lists, compliance segments, active campaign audiences)?

### Stage 2: Before

Inventory all lists via the Lists API (v3):

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

lists, offset = [], 0
while True:
    resp = requests.post(f"{BASE}/crm/v3/lists/search", headers=HEADERS,
                         json={"offset": offset, "count": 100})
    resp.raise_for_status()
    data = resp.json()
    lists.extend(data.get("lists", []))
    if not data.get("hasMore"):
        break
    offset = data.get("offset", offset + 100)
```

For each list, record: list ID, name, processing type (DYNAMIC/MANUAL), member count (`additionalProperties`), created date, last updated date.

Export to CSV for review.

### Stage 3: Execute

Flag lists matching any of these criteria:

1. **Zero members** and created more than 30 days ago
2. **Not referenced** by any workflow, email, or ad audience
3. **Duplicate names** or nearly identical filter criteria
4. **Test/temp lists** (names containing "test", "temp", "copy of", "old")
5. **Static lists** that have not been updated in 6+ months

Cross-reference with workflows and email campaigns before deleting — a list with zero members might still be used as an enrollment trigger.

Present the candidate list to the user and wait for explicit confirmation, then delete via `DELETE /crm/v3/lists/{listId}` or the UI.

### Stage 4: After

1. Re-run the inventory and confirm the deleted lists are gone.
2. Document what was deleted (list name, ID, reason) in a cleanup log.
3. Inform team members if any lists they created were removed.

## Rollback

- HubSpot does not have a list recycle bin. Deleted lists cannot be restored.
- Before deleting, export the list definition (filters/criteria) so it can be recreated if needed.
- Static lists: export member IDs before deletion if the membership data matters.

## Tips

- Run this quarterly as part of the database cleanup routine.
- Establish a naming convention going forward (e.g., prefix with team name or purpose).
- Archive lists by prefixing with "[ARCHIVE]" instead of deleting if you are unsure.

---

<!-- skill: cleanup-properties | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-properties
description: "Archive or delete unused custom properties across all HubSpot object types (contacts, companies, deals). Identifies Salesforce sync properties, test/temp properties, and obsolete form fields."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Properties

Remove or archive unused custom properties. Property bloat slows down forms, confuses users, and makes data mapping harder.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.schemas.*.read` and `crm.schemas.*.write` scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. Archive-first policy (recommended) or direct deletion for clearly dead test properties?
2. Is a Salesforce (or other CRM) sync active? If yes, get the sync property mapping before touching anything.

### Stage 2: Before

Inventory custom properties for each object type via the Properties API (v3):

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

for obj_type in ["contacts", "companies", "deals"]:
    resp = requests.get(f"{BASE}/crm/v3/properties/{obj_type}", headers=HEADERS)
    resp.raise_for_status()
    custom_props = [p for p in resp.json()["results"] if not p.get("hubspotDefined")]
```

For each custom property, record: name, label, object type, type, group, number of records with a value (requires search queries), whether it is used in any form/workflow/list.

### Stage 3: Execute

**Safe to delete:**
- Properties with zero populated records and not used in any form, workflow, or list
- Properties with names containing "test", "temp", "old_", "copy_of"
- Properties created by deactivated integrations

**Handle with care:**
- **Salesforce sync properties** (`hs_salesforce_*` prefix or mapped in sync settings) — do not delete without coordinating with the Salesforce admin
- **Form fields** — check if the property is used on any active form before deleting
- **Workflow dependencies** — check if any workflow reads or sets this property
- **Calculated properties** — check if other calculated properties reference this one

**Archive instead of delete** when:
- The property has historical data that might be needed for reporting
- You are unsure whether anything depends on it

Archive candidates via `DELETE /crm/v3/properties/{objectType}/{propertyName}` (this archives — the property moves to the archived state) after presenting the list to the user and getting explicit confirmation.

### Stage 4: After

1. Archive properties first (HubSpot supports property archiving).
2. Wait 30 days, then delete archived properties that caused no issues.
3. Document all changes in a cleanup log.

## Rollback

- Archived properties can be unarchived at any time.
- Deleted properties cannot be restored. The property definition and all associated data are permanently lost.
- Always archive before deleting to provide a safety window.

## Tips

- Run this quarterly as part of the database cleanup routine.
- Establish a property naming convention going forward (e.g., `team_purpose_detail`).
- Limit who can create custom properties to prevent sprawl.
- HubSpot has a property limit per object type — cleanup prevents hitting it.

---

<!-- skill: cleanup-workflows | category: ongoing-maintenance | scripts: none -->

---
name: cleanup-workflows
description: "Audit and remove inactive, test, or deprecated workflows from HubSpot. Identifies workflows that have never enrolled contacts, workflows turned off for 90+ days, and test workflows."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Cleanup Workflows

Audit HubSpot workflows to remove dead weight. Unused workflows clutter the automation dashboard and make it harder to understand what is actually running.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with the `automation` scope
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Note: The Automation API may return 403 on some plan tiers. If so, audit manually in HubSpot UI under Automation > Workflows.
- Strongly recommended: run `/workflows-as-code` first to export a JSON backup of every workflow — deleted workflows cannot be restored, but an export lets you recreate them via the API.

## Step-by-Step Instructions

### Stage 1: Plan

Confirm with the user before starting:

1. Archive-first policy: turn off, wait a week, then delete (recommended)?
2. Any workflows that must never be touched (compliance, integrations)?

### Stage 2: Before

Inventory all workflows via the v4 Automation API:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

flows, after = [], None
while True:
    params = {"limit": 100}
    if after:
        params["after"] = after
    resp = requests.get(f"{BASE}/automation/v4/flows", headers=HEADERS, params=params)
    resp.raise_for_status()
    data = resp.json()
    flows.extend(data.get("results", []))
    after = data.get("paging", {}).get("next", {}).get("after")
    if not after:
        break
```

For each workflow, record: flow ID, name, enabled status, type, created date, last updated date. Enrollment counts are visible in the UI (Automation > Workflows > Details).

### Stage 3: Execute

Flag workflows matching any of these criteria:

1. **Turned off** for 90+ days with no plans to reactivate
2. **Zero enrollments** ever (likely test or abandoned drafts)
3. **Test workflows** (names containing "test", "temp", "copy of", "draft")
4. **Superseded workflows** replaced by newer versions
5. **Error state** workflows that have been failing consistently

Before deleting, check:
- Does the workflow feed into another workflow (via enrollment trigger or go-to-workflow action)?
- Does the workflow set properties that other workflows depend on?
- Is there any documentation referencing this workflow?

Present the candidate list to the user and wait for explicit confirmation. Then, for confirmed candidates: turn each workflow off first (in the UI, or `PUT /automation/v4/flows/{flowId}` with `isEnabled: false` — the PUT requires the current `revisionId`), wait one week, then delete via `DELETE /automation/v4/flows/{flowId}` or the UI.

### Stage 4: After

1. Re-run the inventory and confirm the deleted workflows are gone.
2. Document deleted workflows in a cleanup log (name, purpose, reason for deletion).
3. Notify workflow owners.

## Rollback

- Deleted workflows cannot be restored in HubSpot.
- A `/workflows-as-code` export taken beforehand contains each workflow's full JSON definition — recreate a deleted workflow with `POST /automation/v4/flows` from its export.
- HubSpot retains workflow activity history on contact records even after the workflow is deleted.

## Tips

- Use folders in the workflows dashboard to organize by team, purpose, or status.
- Prefix draft/test workflows with "[TEST]" so they are easy to identify later.
- Review workflows quarterly as part of the database cleanup routine.

---

<!-- skill: create-segment-lists | category: ongoing-maintenance | scripts: none -->

---
name: create-segment-lists
description: "Create business segment lists in HubSpot for customers, partners, competitors, employees, ICP tiers, and industries. Enables segment-based targeting, suppression, and analytics."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Create Segment Lists

Build a library of segment lists that enable targeted marketing, accurate reporting, and proper suppression. These lists form the foundation of segment-based operations.

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) with `crm.lists.read` and `crm.lists.write` scopes
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- ICP tier property created (run `/create-icp-tiers` first)
- Lifecycle stages cleaned up (run `/fix-lifecycle-stages` first)

## Interview: Gather Requirements

Before executing, collect the following information from the user:

**Q1: What are your key customer segments?**
- Examples: Industry verticals (Manufacturing, Professional Services, Retail, Education, Logistics), company size tiers (Enterprise, Mid-Market, SMB), geographic regions (North America, EMEA, APAC)
- Default: Core business segments (Customers, Partners, Competitors, Internal) plus ICP tiers and engagement-based segments

**Q2: What engagement criteria define "active" for your business?**
- Examples: Email open or click in last 90 days, website visit in last 60 days, form submission in last 30 days, meeting booked in last 90 days
- Default: Any email engagement (open or click) within the last 90 days

## Recommended Segments

### Core Business Segments

| List Name | Type | Criteria |
|-----------|------|----------|
| All Customers | Active | Lifecycle stage = Customer |
| All Partners | Active | Contact type = Partner (or custom property) |
| Competitors | Static | Manually curated from known competitor domains |
| Internal Employees | Active | Email domain matches company domain |
| Suppressed Contacts | Active | Marketing status = non-marketing OR globally unsubscribed |

### ICP-Based Segments

| List Name | Type | Criteria |
|-----------|------|----------|
| ICP Tier 1 | Active | ICP tier property = Tier 1 |
| ICP Tier 2 | Active | ICP tier property = Tier 2 |
| ICP Tier 3 | Active | ICP tier property = Tier 3 |
| Non-ICP | Active | ICP tier property = Non-ICP or unknown |

### Industry Segments

| List Name | Type | Criteria |
|-----------|------|----------|
| [Industry Name] | Active | Industry = [value] |
| (Create one per target industry) | | |

### Engagement Segments

| List Name | Type | Criteria |
|-----------|------|----------|
| Highly Engaged (90 days) | Active | Email open or click in last 90 days |
| Disengaged (6+ months) | Active | No email engagement in 180+ days |
| Never Engaged | Active | No email opens ever AND created 30+ days ago |

## Step-by-Step Instructions

### Stage 1: Plan

1. Run the requirements interview above and decide which segments are relevant to the business.
2. Check for existing lists that overlap — merge or rename rather than creating duplicates.

### Stage 2: Before

1. Confirm the properties these lists depend on are populated (ICP tier, lifecycle stage, industry).
2. Inventory existing lists (`POST /crm/v3/lists/search`) and record the baseline count.

### Stage 3: Execute — Create Lists

Use the Lists API (v3) to create active (smart) lists:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

# Example: Create "All Customers" list
resp = requests.post(f"{BASE}/crm/v3/lists", headers=HEADERS, json={
    "name": "All Customers",
    "objectTypeId": "0-1",  # contacts
    "processingType": "DYNAMIC",
    "filterBranch": {
        "filterBranchType": "OR",
        "filterBranches": [{
            "filterBranchType": "AND",
            "filterBranches": [],
            "filters": [{
                "filterType": "PROPERTY",
                "property": "lifecyclestage",
                "operation": {
                    "operationType": "ENUMERATION",
                    "operator": "IS_ANY_OF",
                    "values": ["customer"],
                },
            }],
        }],
        "filters": [],
    },
})
resp.raise_for_status()
```

Create each list, verify member count, and document the list ID.

For static lists (Competitors), create the list and manually add contacts or import from a CSV.

### Stage 4: After

1. Check member counts for each list — do they match expectations?
2. Verify no contacts appear in mutually exclusive lists (e.g., both Customer and Competitor).
3. Confirm lists are visible to the appropriate teams.

## Rollback

- Lists can be deleted via the API or UI.
- Deleting a list does not affect the contacts in it — only the list definition is removed.
- Check if any workflows or emails reference the list before deleting.

## Tips

- Use a consistent naming convention: `[Category] - Segment Name` (e.g., `[ICP] - Tier 1`, `[Industry] - Manufacturing`).
- Review segment membership quarterly — segments should grow or shrink in expected ways.
- Use these lists as building blocks for email sends, ad audiences, and workflow enrollment triggers.

---

<!-- skill: quarterly-database-cleanup | category: ongoing-maintenance | scripts: none -->

---
name: quarterly-database-cleanup
description: "Run a comprehensive quarterly CRM audit covering list health, bounce monitoring, data quality, scoring calibration, engagement metrics, and property cleanup. Produces a health report with quarter-over-quarter trend comparison."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Quarterly Database Cleanup

A structured quarterly audit that catches data drift before it becomes a crisis. Run this at the start of each quarter (or monthly if the database is large or fast-growing).

## Prerequisites

- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`)
- Python 3.10+ with [`uv`](https://github.com/astral-sh/uv)
- Previous quarter's report (for trend comparison) — optional on first run

## Audit Checklist

### 1. List Health
- Count total active lists, static lists, and unused lists (zero members)
- Identify lists not referenced by any workflow or email
- Flag duplicate or overlapping lists

### 2. Bounce Monitoring
- Count contacts with 1, 2, and 3+ bounces
- Hard bounce rate vs. previous quarter
- Review contacts flagged by bounce monitoring workflow

### 3. Data Quality
- Missing email, company, industry, country, lifecycle stage
- Compare percentages to previous quarter
- Flag any property completeness that dropped more than 5%

### 4. Scoring Calibration
- Review lead score distribution (histogram)
- Check MQL conversion rate — are high-scoring leads actually converting?
- Adjust scoring model if conversion rate is below 10% or above 50%

### 5. Engagement Metrics
- Active contacts (engagement in last 90 days) as % of total
- Zombie contacts (no engagement in 6+ months) as % of total
- Email open rate and click rate trends

### 6. Property Cleanup
- Custom properties with zero populated records
- Properties not used in any list, workflow, or form
- Test/temp properties that should be archived

## Step-by-Step Instructions

### Stage 1: Before — Gather Baselines

1. Locate the previous quarter's report (if it exists) in `reports/`.
2. Run `/hubspot-audit` to get fresh numbers across all dimensions.

### Stage 2: Execute — Deep Review

For each checklist item above:

1. Pull current metrics via the HubSpot API (reuse audit script patterns).
2. Compare to the previous quarter's numbers.
3. Flag any metric that worsened by more than 5 percentage points.
4. Document specific contacts, lists, or properties that need action.

### Stage 3: After — Generate Report

Save a report to `reports/quarterly-cleanup-{YYYY-Q#}.md` with this structure:

```markdown
# Quarterly Database Health Report — YYYY Q#

## Summary

| Metric | Last Quarter | This Quarter | Change |
|--------|-------------|-------------|--------|
| Total contacts | XX,XXX | XX,XXX | +X% |
| Data completeness | XX% | XX% | +X% |
| Bounce rate | X.X% | X.X% | -X% |
| Zombie contacts | XX% | XX% | -X% |
| Unused lists | XX | XX | -XX |

## Action Items

1. [item with owner and deadline]
2. ...

## Detailed Findings

[One section per checklist item with metrics and recommendations]
```

### Stage 4: Rollback

This is a read-only audit — no rollback needed. Action items from the report are executed separately through their respective skills.

## Scheduling

- Set a recurring calendar reminder for the first Monday of each quarter.
- Assign an owner for each action item in the report.
- Review the previous quarter's action items for completion before starting the new audit.

---

<!-- skill: review-bounced-contacts | category: ongoing-maintenance | scripts: none -->

---
name: review-bounced-contacts
description: "Weekly manual review of contacts with 3+ bounce events. Decide whether to delete or attempt recovery for each flagged contact. Prevents over-suppression while removing truly bad data."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Review Bounced Contacts

A weekly manual review process for contacts flagged with 3+ bounces. The bounce monitoring workflow auto-suppresses these contacts, but a human should decide whether to permanently delete or attempt recovery.

## Prerequisites

- Bounce monitoring workflow active (run `/bounce-monitoring-workflow` first)
- `email_health_flag` custom property exists on contacts
- A HubSpot private app access token (`HUBSPOT_ACCESS_TOKEN` in `.env`) for scripted pre-filtering

## Step-by-Step Instructions

### Stage 1: Before

Use the CRM Search API to pull contacts where `email_health_flag` is set:

```python
import os, requests
from dotenv import load_dotenv

load_dotenv()
TOKEN = os.environ["HUBSPOT_ACCESS_TOKEN"]
BASE = "https://api.hubapi.com"
HEADERS = {"Authorization": f"Bearer {TOKEN}", "Content-Type": "application/json"}

resp = requests.post(f"{BASE}/crm/v3/objects/contacts/search", headers=HEADERS, json={
    "filterGroups": [{"filters": [
        {"propertyName": "email_health_flag", "operator": "EQ", "value": "true"},
    ]}],
    "properties": ["email", "firstname", "lastname", "company",
                   "hs_email_bounce", "hs_email_hard_bounce_reason_enum",
                   "lifecyclestage", "hubspot_owner_id"],
    "limit": 100,
})
resp.raise_for_status()
results = resp.json()["results"]
```

Export results to a CSV for review.

### Stage 2: Execute — Review Each Contact

For each flagged contact, check:

1. **Is the email domain active?** Run a quick MX record lookup or visit the domain.
2. **Is this a known customer or high-value contact?** Check lifecycle stage and deal history.
3. **What is the bounce reason?** Hard bounce (invalid mailbox) vs. soft bounce (mailbox full, temporary error).

**Decision matrix:**

| Domain active? | High value? | Bounce type | Action |
|---------------|-------------|-------------|--------|
| No | Any | Any | Delete |
| Yes | No | Hard | Delete |
| Yes | No | Soft | Keep suppressed, recheck next quarter |
| Yes | Yes | Hard | Attempt to find updated email |
| Yes | Yes | Soft | Keep suppressed, monitor |

### Stage 3: After — Apply Decisions and Log

1. **Delete** contacts marked for deletion via the HubSpot UI or API batch delete.
2. **Clear** the `email_health_flag` on all reviewed contacts.
3. Log the review results (deleted count, kept count, recovery attempts) for the quarterly report.

## Rollback

- Deleted contacts can be restored from HubSpot's recycling bin within 90 days.
- Contacts kept as suppressed can be restored to marketing status via a workflow or manual update in the UI.

## MCP Note

This weekly triage is exactly the interactive, judgment-heavy work HubSpot's MCP server is good at (see `/connect-hubspot-mcp`): ask Claude to pull each flagged contact's details, deal history, and bounce reason conversationally while you decide delete-vs-recover.

## Frequency

Run weekly, ideally Monday morning. Should take 5-15 minutes depending on volume. If volume exceeds 50 contacts per week, investigate the root cause (bad list source, form spam, etc.).

---

<!-- skill: weekly-cleanup-routine | category: ongoing-maintenance | scripts: none -->

---
name: weekly-cleanup-routine
description: "A lightweight 5-minute weekly health check covering bounce monitoring, new contact quality, workflow health, list growth trends, and data quality sampling. Quick early warning system, not a comprehensive audit."
license: MIT
metadata:
  author: tomgranot
  version: "1.1"
  category: ongoing-maintenance
---

# Weekly Cleanup Routine

A fast, 5-minute weekly check that catches problems early. This is not a deep audit — it is a spot-check designed to surface issues before they compound.

## Prerequisites

- HubSpot portal access
- Bounce monitoring workflow active (run `/bounce-monitoring-workflow` first)

## The 5-Minute Checklist

### Stage 1: Before — Open Dashboards

Open HubSpot in your browser. Have the following views ready:
- Email health dashboard
- Workflow dashboard
- Contacts list view (sorted by create date, descending)

### Stage 2: Execute — Five Spot Checks

#### 1. Bounce Monitoring (1 min)
- Check the `email_health_flag` list. How many contacts were flagged this week?
- If more than 10, investigate the source (bad import? form spam?).
- If zero, confirm the workflow is still active.

#### 2. New Contact Quality (1 min)
- Filter contacts created in the last 7 days.
- Spot-check 5-10 new contacts. Do they have:
  - Valid email addresses?
  - Company name populated?
  - Lifecycle stage set?
- If quality is low, check the source (which form, import, or integration created them).

#### 3. Workflow Health (1 min)
- Open the workflow dashboard. Look for:
  - Any workflows showing errors (red indicators)
  - Any workflows with zero enrollments that should be active
  - Any workflows that were accidentally turned off

#### 4. List Growth Trends (1 min)
- Check your key segment lists (customers, MQLs, suppressed).
- Is any list growing or shrinking unexpectedly?
- A sudden spike in suppressed contacts could indicate a deliverability problem.

#### 5. Data Quality Sample (1 min)
- Pull 10 random contacts from the database.
- Check: email, company, lifecycle stage, owner.
- If more than 2 out of 10 have gaps, flag for a deeper review.

### Stage 3: After — Log and Escalate

Keep a simple weekly log (spreadsheet or note):

| Date | Bounces | New Contact Quality | Workflow Issues | List Anomalies | Data Quality |
|------|---------|-------------------|-----------------|----------------|--------------|
| YYYY-MM-DD | X flagged | Good/Bad | None/Details | None/Details | X/10 complete |

Escalate anything that appears two weeks in a row.

### Stage 4: Rollback

This is a read-only review — no rollback needed. Any fixes identified are executed through their respective skills.

## Scheduling

- Run every Monday morning.
- Set a recurring 15-minute calendar block (5 minutes for the check, 10 minutes buffer for any follow-up).
- If you miss a week, do not double up — just run the next scheduled check.