I spent a day building a read-only tool that answers one question about a HubSpot portal: what is the cheapest tier its current usage actually requires?
Everything below was probed with GET requests against a live portal on 2 August 2026. Where it contradicts HubSpot's documentation, trust this. The tool is open source and MIT licensed: meticulosity/hubspot-license-fit.
1. The API cannot tell you what a portal is subscribed to
This is the constraint the whole design hangs off, so it is worth establishing first.
GET /account-info/v3/details
{"portalId": 0000000, "accountType": "STANDARD", "timeZone": "...",
"companyCurrency": "USD", "additionalCurrencies": ["CAD", "SAR"],
"dataHostingLocation": "na1", "uiDomain": "app.hubspot.com"}
No subscription object, no tier field, no entitlement list. accountType describes the kind of account, not the tier purchased.
So a tool cannot report what somebody pays. It can only work backwards from evidence: three custom objects exist, therefore some Enterprise subscription is in play. That is a floor, not a verdict, and the difference matters because the second one is a claim about a contract you cannot see.
2. You cannot introspect a private app token
HubSpot documents GET /oauth/v1/access-tokens/{token} for checking a token's scopes. It does not work for private app tokens, which is what everyone actually uses:
| Attempt | Result |
|---|---|
GET /oauth/v1/access-tokens/{pat-token} |
400 The access token must have the correct format
|
GET /oauth/v2/access-tokens/{pat-token} |
404 |
GET /oauth/v1/private-apps/access-tokens/{pat-token} |
404 |
That endpoint is for OAuth access tokens. A pat-na1-... token has no introspection path at all.
The design consequence is the interesting bit. My original plan was "on startup, check the token's scopes and refuse to run if any of them can write." That is unimplementable. So the guarantee had to move from their configuration to my code: one function that builds every request and refuses anything that is not a GET, plus a test that greps the source.
def _request(self, method, path, params=None):
if method != "GET":
raise ReadOnlyViolation(
"this tool issues GET requests only, refused: %s %s" % (method, path))
WRITE_VERBS = re.compile(r"""["']\s*(POST|PATCH|PUT|DELETE)\s*["']""")
def test_no_write_verb_appears_in_any_source_file():
offenders = [...] # scan every .py in the package
assert offenders == []
It is a weaker promise in one sense and a much stronger one in another. I cannot verify what your token is allowed to do. I can verify that I never try.
3. A 403 never tells you which scope is missing
Four different scope failures, four identical response bodies:
{"status":"error",
"message":"This app hasn't been granted all required scopes to make this call.
Read more about required scopes here: https://developers.hubspot.com/scopes.",
"correlationId":"..."}
If you want to tell a user which scope to grant, you carry your own endpoint-to-scope mapping. There is nothing in the response to parse.
4. A 403 and a zero are different answers, and conflating them is dangerous
Obvious when written down, easy to get wrong in code. GET /settings/v3/users/teams returned 403 on a portal that plainly had five teams.
If your model has two states (found / not found), that 403 collapses into "no teams." For a tool whose output influences whether somebody downgrades a subscription, that is the one error that costs a user a feature they were relying on.
So the gate model has three states, and the third never renders as the second:
FIRES = "fires" # something requires a tier
CLEAR = "clear" # read successfully, not present
UNKNOWN = "unknown" # could not read, so nothing is claimed
5. The workflow list endpoint returned 0 flows, then 113
Same token, seconds apart:
GET /automation/v4/flows -> {"results": []}
GET /automation/v4/flows -> {"results": [ ...113 flows... ]}
It paginates via paging.next.after and it is flaky. A tool that trusts one unpaginated call will confidently report "no workflows found" on a portal running a hundred of them, which is worse than crashing because it looks like an answer.
Paginate, and retry an empty first page before believing it:
if pages == 0 and not page and retry_empty_first_page and not retried_empty:
retried_empty = True
time.sleep(self.pause * 4)
continue
GET /automation/v4/flows/{id} is reliable. Only the list misbehaves. (/automation/v3/workflows returns a different, smaller set: 82 against 113. It is not a substitute.)
6. HubSpot ships its own calculated properties into every portal
Calculated properties are a paid-tier feature, so "does any property carry a calculation formula?" looks like a reasonable gate check.
It is not. On the portal I tested, 21 contact properties carried calculationFormula, and all 21 had hubspotDefined: true. days_to_close and friends ship everywhere, including free portals. User-created calculated properties: one.
with_formula = [p for p in rows if p.get("calculationFormula")]
user_formula = [p for p in with_formula if not p.get("hubspotDefined")]
Only the second list means anything. Without that filter the tool fires a paid-tier claim on every portal in existence.
7. Association labels have the same trap
GET /crm/v4/associations/contacts/companies/labels
[{"category": "HUBSPOT_DEFINED", "label": "Primary"},
{"category": "HUBSPOT_DEFINED", "label": "Billing Contact"},
{"category": "USER_DEFINED", "label": "Renewal Owner"}]
Every portal ships the HUBSPOT_DEFINED ones. Filter on category == "USER_DEFINED".
The general lesson, which cost me two near-misses in one afternoon: when a platform seeds default data, presence of a thing is not evidence that somebody chose the thing. Check provenance, not existence.
8. Three refusals that can be routed around
A 403 or a 404 on the obvious endpoint does not always mean the answer is unavailable. It sometimes means you are asking the wrong endpoint.
Teams: 403. But GET /crm/v3/owners returns a teams array per owner, and /settings/v3/users returns primaryTeamId and secondaryTeamIds. Five named teams were readable while the teams endpoint itself refused.
Business units: 404 on /business-units/v3/user/. But GET /marketing/v3/emails carries businessUnitId per email, and six of 112 emails sat on a non-default unit. That answers the question the direct endpoint would not.
Sequences: 400. GET /automation/v4/sequences returns query param userId may not be null. Sequences are per user. Pass ?userId=N and sample users until one has some.
When you route around, say so in the output. My reports print "5 teams in use, read from owner records, because the teams endpoint was refused," because a reader deserves to know which source answered.
9. Some HubSpot scopes have no read-only variant
This one changed the product, not just the code.
content, external_integrations.forms.access, and behavioral_events.event_definitions.read_write are all offered only as combined read-and-write scopes.
For a diagnostic tool the implication is uncomfortable: reading marketing emails, forms, or custom behavioral events requires asking the user to grant write access to their portal. I decided not to. Those checks report as unreadable with that as the stated reason, and the tool's scope list contains read-only scopes exclusively.
Same reasoning killed one more signal. GET /crm/v3/lists/search returns 405: it is a POST with a body. Since the entire promise is that the codebase contains no write verbs, I dropped list counts rather than carve out an exception. Losing a signal was cheaper than losing the property that makes the guarantee testable.
The thing I would tell myself at the start
Write down which of your findings are observations and which are claims, and keep them in separate parts of the output.
Counts read from an API are facts. They stay true. Statements like "custom objects require Enterprise" are claims about a vendor's packaging, and vendors move packaging: HubSpot's own catalogue now puts Enterprise deal pipelines at 100 per account where my older reference data said 50.
So every tier claim in the output carries the date it was verified, and the table expires itself after 60 days, downgrading everything to "unverified, confirm before quoting." The counts never get downgraded, because they never went stale.
Full source, the fixtures, and the complete measurement log are in the repo: meticulosity/hubspot-license-fit. It is Python with no dependencies, and python3 -m license_fit --dry-run renders a full sample report without a token or a network call.
Top comments (0)