Skip to content

HubSpot audiences - #45

Merged
stan-rym merged 1 commit into
mainfrom
feat/hubspot-audiences
Oct 19, 2026
Merged

stan-rym merged 1 commit into
mainfrom
feat/hubspot-audiences

Conversation

@stan-rym

Copy link
Copy Markdown
Collaborator

What it does

Adds HubSpot audiences, built the way Salesforce audiences work: a HubSpot list's members, or a CRM search by property filters, becomes a LinkedIn matched audience of contacts or companies.

  • MCP tool audience_from_hubspot: name, optional type (contact or company), exactly 1 source (listId, or filters of property, operator and value, all ANDed, up to 6), optional accountId and dryRun.
  • CLI adwarp audience from-hubspot -n <name> (--list <id> | --filter <prop>:<op>:<value> ...) [-t contact|company] [--dry-run].
  • Reads every page. A list's type comes from HubSpot (0-1 contacts, 0-2 companies), and a mismatched type is refused. A search builds a contact audience unless type says company.
  • Contacts read only email. Companies read name and domain, and domains become https:// websites.
  • Records go through the CSV path: cleanAudienceRows (split out of cleanAudienceCsv) and uploadCleanedAudience (split out of uploadAudienceFromFile). Contact emails are trimmed, lowercased, validated, deduped and SHA-256 hashed, so only hashes leave the machine.
  • The result reports read, valid, uploaded, skipped and skippedReasons (no email, invalid email, duplicate email, no name or domain, not found in HubSpot).
  • Dry run: reads and cleans, returns the counts and a sample of 5 rows (emails masked, shown with the hash that would upload), and makes no LinkedIn call. It applies the same size rules as the upload.
  • Company CSV uploads now also respect LinkedIn's 300,000 row cap, which only contact lists checked before.

HubSpot API

Checked against HubSpot's developer docs on 2026-10-18. HubSpot moved to date-based versions in March 2026 and ends support for the v3 paths in September 2027, so this pins 2026-09 (HUBSPOT_API_VERSION), whose request and response shapes match v3 for these endpoints:

  • POST /crm/objects/2026-09/{contacts|companies}/search: filterGroups[].filters[], 200 per page, cursor in paging.next.after, at most 10,000 results per query (a broader search is refused before any upload), 5 requests per second.
  • GET /crm/lists/2026-09/{listId} for the list's objectTypeId, then GET /crm/lists/2026-09/{listId}/memberships for its recordIds.
  • POST /crm/objects/2026-09/{contacts|companies}/batch/read, 100 ids per call. A 207 for deleted members counts them as not found.
  • Requests are paced under the search limit and the private app burst limit (100 per 10 seconds on Free and Starter), and 429 and 5xx retry with the shared policy.

Scopes and token

A HubSpot private app (HubSpot now files these under Development, then Legacy apps) with 3 read-only scopes:

  • crm.objects.contacts.read
  • crm.objects.companies.read
  • crm.lists.read

The token goes in ~/.adwarp/hubspot.json as { "accessToken": "..." } (mode 0600) or in ADWARP_HUBSPOT_TOKEN, and the environment variable wins. No OAuth. env() now accepts names added after the rename, which have no legacy fallback.

Local only

The hosted route now registers tools with { google: false, hubspot: false }, so the endpoint never registers audience_from_hubspot. A token in a server environment variable would let any bring-your-own-credentials caller read that HubSpot account. gen-tools.mjs checks the new route call, tools.json has the tool as hosted: false, and docs/hosted.md lists it under the hosted limitations.

Change journal

Audience creation runs through createLinkedInAds(), the same client and hook as Salesforce audiences, so it is captured exactly as they are. The journal tracks campaign groups, campaigns and creatives only, so DMP segment creation is journaled for neither source today.

Tests

packages/core/test/hubspot.test.mjs, with HubSpot answered by a stubbed fetch and LinkedIn by a fake client. No real HubSpot or LinkedIn calls, and no credentials.

  • missing token: names the env var, the file and the 3 scopes, and makes no request
  • search pagination: 3 pages, after cursors, ANDed filters, skip reasons, masked sample, dry run uploads nothing
  • search over 10,000 results stops after the first page
  • list memberships: 2 membership pages, batch reads of 100 and 50, a 207 not-found member, and an upload whose CSV is only SHA-256 hashes
  • company list: type from the list, domains to websites, mismatched type refused
  • empty result: the dry run reports zeros, the upload stops before LinkedIn
  • exactly 1 source, env token over file token, 403 naming the missing scope, CLI filter parsing

Run locally: pnpm -r build, pnpm -r typecheck, pnpm test (16 + 28 + 10 pass), pnpm gen:tools with no drift, and pnpm pack plus scripts/smoke.mjs (37 tools listed by adwarp-mcp and adwarp mcp).

Docs

New docs/hubspot.md (order 12, after Salesforce; the pages after it moved down 1). Updated tools.md, cli.md, config.md, hosted.md, index.md, platforms-linkedin-ads.md, skills.md, the README (tool list, CLI, configuration, a HubSpot integration section linking https://adwarp.dev/docs/hubspot), AGENTS.md, CONTRIBUTING.md and the adwarp-audiences skill. tools.json is regenerated.

Not merged here: it ships in the next release, and the website's /integrations/hubspot page follows once that release is out.

🤖 Generated with Claude Code

Add audience_from_hubspot and `adwarp audience from-hubspot`: a HubSpot list's
members, or a CRM search by property filters, becomes a LinkedIn matched
audience of contacts or companies, the way Salesforce audiences work.

- Auth is a read-only HubSpot private app token (crm.objects.contacts.read,
  crm.objects.companies.read, crm.lists.read) from ADWARP_HUBSPOT_TOKEN or
  ~/.adwarp/hubspot.json. No OAuth.
- Reads every page through HubSpot's 2026-09 date-versioned API (search,
  list memberships, batch read), paced under the search and private app
  limits, and refuses a search above HubSpot's 10,000 result cap.
- Records go through the CSV cleaner and upload path (cleanAudienceRows,
  uploadCleanedAudience), so contacts leave only as SHA256 hashes. The
  result reports read, valid, uploaded and skipped with reasons.
- dryRun reads and cleans, returns counts and a masked sample, and makes no
  LinkedIn call.
- Local only: the hosted route registers tools with hubspot: false, so the
  tool is hosted: false in tools.json.
- Company CSV uploads now also respect the 300,000 row cap.
- Tests stub fetch for HubSpot and use a fake LinkedIn client.
- New docs/hubspot.md; tools, cli, config, hosted, index, README, AGENTS and
  the adwarp-audiences skill updated; tools.json regenerated.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 19, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
liam Ready Ready Preview Oct 19, 2026 12:50am UTC

Request Review

@stan-rym
stan-rym merged commit 8a9ec62 into main Oct 19, 2026
4 checks passed
@stan-rym stan-rym mentioned this pull request Oct 19, 2026

This branch was successfully deployed

1 active deployment
Preview — cc2a6205 Deployed Oct 19, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant