WorkflowsMCP

Expansion opportunity report: Stripe → HubSpot → Notion → Slack

Score Stripe plan × HubSpot size weekly; Notion list + Slack Top-5 for sales.

@workflowsmcpVerified finance-opsexpansion-revenuereportingsalesaccount-scoring

Stripe already knows the plan; HubSpot already knows company size — neither ships a weekly expansion shortlist from both. This pulls active subscriptions with a read-only Stripe key, joins each customer to its HubSpot company via metadata.hubspot_company_id, scores plan-tier gap × employee bucket × seat usage, upserts the ranked rows into Notion, and posts a Top-5 digest to sales Slack. HubSpot's paid health scores still need you to invent the rules; marketplace Stripe sync only moves fields. Read-only end to end — nothing written back.

How it flows

  1. 01

    Monday 08:00 trigger

    An hour after the weekly investor-update draft's own Monday Stripe pull, so the two jobs do not hit the subscription list in the same minute.

  2. 02

    Stripe subscriptions pulled

    Active subscriptions with customer expanded; plan from price.metadata.plan_tier, seats from item quantity. Meter summaries only when usage-based billing is already on.

  3. 03

    HubSpot companies joined by metadata id

    Lookup by customer.metadata.hubspot_company_id; domain match is an optional setup hint for gaps, with free-mail and multi-brand misses counted as unmatched.

  4. 04

    Expansion score computed and filtered

    plan_gap × employee bucket × seat_ratio against the seat-cap table. Score ≥50 enters the list; below 50 stays out of the digest.

  5. 05

    Notion rows upserted for the week

    Query by Domain + Week of first; patch score/status on a hit, create on a miss. Same company never doubles inside one week.

  6. 06

    Top-5 digest posted to sales Slack

    One channel message: how many new candidates, the Top-5 one-liners, and the Notion database link.

Set up each app

Work through these in order — later apps usually need a token or an id from an earlier one.

Stripe

Active subscriptions, plan tier, and seat quantity — read-only

  1. 01

    Create a restricted, read-only key

    Dashboard → Developers → API keys → Create restricted key. Grant Read on Subscriptions, Customers, and Products/Prices; leave every Write permission off. A reporting job has no business holding a key that can change a subscription or issue a refund. If you later enable the optional metered-usage path below, also grant Read on Billing Meters — most seat-priced SaaS accounts never need it.

  2. 02

    Put the HubSpot company id on each Stripe customer

    This workflow never writes to Stripe. At subscription signup (sales or onboarding), set customer.metadata.hubspot_company_id to the HubSpot Company record id — once, by hand or by whatever already opens the account. That explicit id is the primary join key. Domain matching from customer.email is only a setup hint when metadata is missing: take the email domain, compare it to Company.domain, and silently skip free-mail domains (gmail.com, qq.com, and the rest) plus multi-brand splits into an "unmatched" count — never abort the whole batch. Prefer fixing the metadata over leaning on the hint.

  3. 03

    List active subscriptions and normalise the plan from price metadata

    GET /v1/subscriptions with status=active, limit=100, and expand[]=data.customer. Paginate with starting_after when has_more is true — the default page is 10 and a truncated page silently looks like the book shrank. Read plan tier from items.data[0].price.metadata.plan_tier (set that key on each Price to your own names: starter / growth / enterprise); fall back to price.nickname only when the metadata key is empty. Seat usage defaults to items.data[0].quantity. This is a weekly joint report, not Stripe's own Workflows-for-Slack event forwarder — that app pushes payment and subscription events live; it does not cross HubSpot company size or build a ranked shortlist.

    Active subscriptions + customer
    curl -G https://api.stripe.com/v1/subscriptions \
      -u "$STRIPE_RESTRICTED_KEY:" \
      -d status=active \
      -d limit=100 \
      -d "expand[]=data.customer"
    # page with starting_after=<last subscription id> while has_more=true
    # plan: items.data[0].price.metadata.plan_tier (else price.nickname)
    # seats: items.data[0].quantity
    # join:  customer.metadata.hubspot_company_id
  4. 04

    Seat quantity by default; Billing Meters only if you meter

    Default usage signal is subscription item quantity (seats). Compare it to a seat-cap table you maintain yourself — a code constant or a small Notion config table — because neither Stripe nor HubSpot stores "how many seats this plan allows". Only if the account already uses Stripe usage-based billing, optionally GET /v1/billing/meters/{meter_id}/event_summaries for that customer and window and read aggregated_value as a near-limit signal. Skip the Meters call entirely on ordinary seat pricing. Keep the job weekly: Stripe also caps read volume relative to charge volume over 30 days, and a daily full-subscription pull is the schedule that starts to matter on early accounts.

HubSpot

Company size and industry for the join — read-only

  1. 01

    Create a private app with companies-read only

    Development → Legacy apps → Create legacy app (HubSpot’s current private-app path; older accounts may still show Settings → Integrations → Private Apps). Scope: crm.objects.companies.read only. Do not grant companies.write — this workflow never writes plan_tier or anything else back onto the Company. Token is on the Auth tab behind "Show token".

  2. 02

    Confirm the company properties you will read

    Plan tier does not live on HubSpot here — it comes from Stripe price metadata in the previous section. On each Company you only need name, domain, numberofemployees, and industry filled in. Scoring buckets run locally after the GET below — you do not need HubSpot Search GT/LT filters for the default path. Optional: call GET /crm/v3/properties/companies/numberofemployees once and confirm fieldType is numeric if you later add Search filters on that property.

  3. 03

    Fetch each company by the metadata id

    For every Stripe customer that has metadata.hubspot_company_id, GET /crm/v3/objects/companies/{id} with the four properties above. Prefer these direct GETs over Search — Search is separately limited to 5 req/s per account. Customers with no metadata id are either skipped or, if you chose the domain hint, matched once and then counted as unmatched on miss — they never block the rest of the run.

    GET company by id
    curl -G "https://api.hubapi.com/crm/v3/objects/companies/$HUBSPOT_COMPANY_ID" \
      -H "Authorization: Bearer $HUBSPOT_PRIVATE_APP_TOKEN" \
      --data-urlencode "properties=name,domain,numberofemployees,industry"
  4. 04

    Know what HubSpot already offers — and what it does not

    Customer Success Workspace health scores (Service Hub Professional/Enterprise) are a paid scoring shell: you bring the usage fields and invent the rules yourself; nothing there reads Stripe plan tiers and auto-builds this shortlist. The marketplace "Stripe Data Sync" connector (and its developer's own sync product) moves Contacts/Companies/Products fields — and optionally Subscriptions as custom objects on Enterprise — but its feature list stops at field sync and LTV/MRR/CAC-style metrics, not a plan-distribution × company-size expansion queue. This workflow's value is the free weekly joint report that does not consume a Service Hub seat and does not depend on that connector being installed.

Notion

Ranked expansion list, one row per company per week

  1. 01

    Create the "Expansion Opportunities" database

    Properties (names matter — the API writes by name): Company (Title), Domain (Text), Plan tier (Select), Employees (Number), Industry (Select), Expansion score (Number), Status (Select: New / Reviewing / Contacted / Dismissed), Week of (Date). Optionally keep a second tiny config table for seat caps per plan name if you do not want those caps in code.

  2. 02

    Share the database with insert and update rights

    Internal Integration Token. Open the database → ••• → Connections → Add connection → your integration. Keep Insert content, Read content, and Update content on — a read-only share cannot create or refresh weekly rows. Notion-Version: 2026-03-11.

  3. 03

    Score with a boring table, then upsert by Domain + Week of

    Keep the scoring table in one place and dull enough that sales can argue with it — same shape as an inbound lead score, different inputs. Anything at 50 or above lands in Notion and competes for the Slack Top-5; below 50 is dropped from the digest (still fine to log elsewhere if you want). Retrieve the database once for data_sources[0].id. Before creating a row, POST /v1/data_sources/{data_source_id}/query filtered on Domain + Week of (Notion-Version: 2026-03-11): hit → PATCH the page's Expansion score / Status; miss → POST /v1/pages with parent type data_source_id. The old /v1/databases/{id}/query path is deprecated.

    expansion score
    plan_gap     starter→growth +25   growth→enterprise +35   already top tier  0 (skip)
    employees    201+ +30   51-200 +20   11-50 +10   1-10 +0
    seat_ratio   ≥80% of your seat cap +25   50–79% +10   <50% +0
    threshold    ≥50 → Notion row + Slack Top-5 pool; <50 stays out of the digest
  4. 04

    Create (or patch) the weekly row

    POST only after the Domain + Week of query misses. Parent on the data source id you retrieved above so create and query stay on the same object.

    POST /v1/pages · Notion-Version: 2026-03-11
    curl -X POST "https://api.notion.com/v1/pages" \
      -H "Authorization: Bearer $NOTION_TOKEN" \
      -H "Notion-Version: 2026-03-11" \
      -H "Content-Type: application/json" \
      -d '{
        "parent": { "type": "data_source_id", "data_source_id": "'"$EXPANSION_DB_DATA_SOURCE_ID"'" },
        "properties": {
          "Company": { "title": [{ "text": { "content": "Northwind" } }] },
          "Domain": { "rich_text": [{ "text": { "content": "northwind.co" } }] },
          "Plan tier": { "select": { "name": "growth" } },
          "Employees": { "number": 180 },
          "Expansion score": { "number": 80 },
          "Status": { "select": { "name": "New" } },
          "Week of": { "date": { "start": "2026-08-10" } }
        }
      }'

Slack

Sales-channel digest for the week's Top-5

  1. 01

    Create the bot and add chat:write

    api.slack.com/apps → Create New App → From scratch → OAuth & Permissions → Bot Token Scopes → chat:write. Install to the workspace, copy the bot token (xoxb-…), and /invite the bot into #sales-expansion (or your sales channel). This is a channel broadcast for the whole team, not a per-rep DM.

  2. 02

    Post the weekly Top-5 with a Notion link

    After Notion upserts finish, take the highest-scoring new-or-updated rows (cap at 5), and chat.postMessage once with the count, the Top-5 one-liners, and a link to the Expansion Opportunities database. Read ok from the response body — Slack returns HTTP 200 with {"ok":false,"error":"..."} on auth failures, so a status-code-only check lies.

    chat.postMessage
    curl -X POST https://slack.com/api/chat.postMessage \
      -H "Authorization: Bearer $SLACK_BOT_TOKEN" \
      -H "Content-Type: application/json; charset=utf-8" \
      -d '{
        "channel": "C0SALESXPAND",
        "text": "5 new expansion candidates this week — top: Northwind (180 employees, growth tier, score 80)",
        "blocks": []
      }'