Expansion opportunity report: Stripe → HubSpot → Notion → Slack
Score Stripe plan × HubSpot size weekly; Notion list + Slack Top-5 for sales.
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
- 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.
- 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.
- 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.
- 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.
- 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.
- 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
- 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.
- 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.
- 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 + customercurl -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 - 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
- 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".
- 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.
- 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 idcurl -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" - 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
- 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.
- 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.
- 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 scoreplan_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 - 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-11curl -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
- 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.
- 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.postMessagecurl -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": [] }'