Agency & White-Label Services
How Do You Know a Tool Cannot Write to Your Client's HubSpot Portal?
HubSpot cannot tell you what a private app token is allowed to do. So the read-only promise has to be made somewhere else, and here is where.

Key Takeaways
- HubSpot cannot introspect a private app token. GET /oauth/v1/access-tokens/{token} returns 400 for any pat- token, so a tool that claims it checked its own scopes on startup is describing something that does not work.
- A promise about your client's configuration is unverifiable. A promise about the tool's own code is auditable in ten seconds, which makes it the stronger of the two even though it claims less.
- Three HubSpot scopes have no read-only variant: content, external_integrations.forms.access, and behavioral_events.event_definitions.read_write. Any tool requesting them is asking for write access to a client portal.
- A refused read is not an empty result. A 403 on the teams endpoint reported as 'no teams' is the kind of finding that gets a client to downgrade and lose something they were using.
- HubSpot ships its own calculated properties and association labels into every portal. On the portal we tested, all 21 contact properties carrying a calculation formula belonged to HubSpot, not to us.
Every agency has had this conversation. You want to point a tool at a client's HubSpot portal, and somebody on their side asks the reasonable question: what can it actually do in there?
The comfortable answer is "it's read-only." We recently found out that the obvious way to prove that does not exist, which turned out to be the more useful thing to know.
This came out of building HubSpot License Fit, a free tool that works out what tier a client portal actually requires. It is read-only by design, so it needed to make that promise properly.
Why can't a tool just check its own permissions?
Because HubSpot will not tell it. The plan was ordinary: on startup, look at the token's scopes and refuse to run if any of them can write.
HubSpot documents GET /oauth/v1/access-tokens/{token} for exactly this. It does not work for the token type agencies actually create:
| 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 private app token, the pat-na1-... kind you generate inside a portal's settings, has no introspection path at all.
So if a vendor tells you their tool checks its own permissions on startup and stops if you granted too much, ask them how. On private app tokens, that check cannot be performed.
Where the promise actually belongs
The instinct is to build a weaker version of the same check. That instinct is wrong, because anything done at runtime with a token that cannot be inspected is theatre.
The promise has to move from your client's configuration, which the tool cannot see, to the tool's own code, which anybody can read. In practice that means one function that constructs every request and refuses any method except GET, plus a test that fails the build if a write verb appears anywhere in the source.
It is a strictly smaller claim. It says nothing about whether the token was over-scoped. But it is the one that can be checked, in about ten seconds, by somebody who does not trust the vendor at all.
That turns out to be the better sentence in a client conversation too. "We check your token" asks them to trust a mechanism they cannot see. "Here is the single function every request goes through, and here is the test that fails if anyone adds a write" is something their own developer can confirm over lunch.
It has a cost, and paying it is the point. HubSpot's list endpoint requires a POST with a body. Under a no-write-verbs rule that data is simply unavailable, so the tool does not read lists at all and says so in its output. Losing a signal was cheaper than losing the property that makes the guarantee checkable.
The three scopes worth refusing
While mapping which read scopes were needed, a sharper problem appeared. HubSpot offers no read-only variant of these:
content, which covers site pages, blog and marketing emailexternal_integrations.forms.access, for formsbehavioral_events.event_definitions.read_write, for custom behavioral events
Each grants write alongside read. So reading a client's marketing emails, to check whether they use memberships, would mean asking that client to grant write access to their content, in order to run a report.
Worth knowing when you evaluate any HubSpot tool: look at the scope list it asks for, not the description it gives itself. A tool requesting content can rewrite website pages, whatever its marketing says. Ours declines to ask, reports those checks as unreadable, and states that as the reason.
A refusal is not a zero, and this is where audits go wrong
This is the failure with the worst blast radius, and it lands on your client rather than on you.
Reading a portal with a properly scoped read-only token, some endpoints still refuse. HubSpot's teams endpoint returns 403 on most tokens even when teams plainly exist. A tool that only knows "found" and "not found" turns that refusal into "this portal has no teams."
Now imagine that finding inside a subscription review. It points at a downgrade, the downgrade happens, and something the client was using stops working. The tool was never able to see teams at all; it just had no way to say so.
Two things follow for anyone doing this work by hand as well as with tooling. HubSpot's 403 never names the missing scope, so you cannot rely on the error to tell you what to grant. And where a refusal can be routed around, route around it and say which source answered: team membership is visible on owner records even when the teams endpoint refuses, so the honest line is "5 teams in use, read from owner records, because the teams endpoint was refused."
What HubSpot ships itself, and why it fools audits
Two traps that would have made our tool wrong on every portal, including free ones.
Calculated properties sit behind a paid tier, so checking for properties with a calculation formula looks fair. It is not. HubSpot ships its own calculated properties into every portal. On the portal we tested first, 21 contact properties carried a calculation formula and every one of them was HubSpot's. Genuinely created by us: one.
Association labels are the same. Every portal ships Primary and Billing Contact by default. Only labels somebody created say anything about the subscription.
The general lesson applies well beyond tier checks, and it is worth carrying into any portal audit you run: when a platform seeds default data, the presence of a thing is not evidence that anybody chose it. Check who created it, not whether it exists. That distinction is also what separates a real portal audit from a feature inventory.
Facts and claims should not sound the same
The last decision is the one most worth stealing for your own client deliverables.
Counts read from a portal are facts. 828 contact properties is true, and will stay true about today, forever. But "custom objects require Enterprise" is a claim about HubSpot's packaging, and HubSpot moves its packaging: their own catalogue now puts Enterprise deal pipelines at 100 per account, where our older reference data said 50.
So the report keeps them apart. One section is counts, with no claims in it. Another is claims, each naming the observation that drove it and the date it was verified, and the whole tier table expires after 60 days, downgrading every claim to "unverified, confirm before quoting" rather than quietly serving stale packaging data to somebody about to advise a downgrade.
If a document mixes those two in one confident voice, the stale half poisons the reliable half, and the reader cannot tell which is which. That is as true of the audit deck you send a client as it is of a command line tool.
Try it against your own portal first
The tool is free, open source and MIT licensed. Python with no dependencies, so there is nothing to install:
git clone https://github.com/meticulosity/hubspot-license-fit.git
cd hubspot-license-fit
python3 -m license_fit --dry-run
The dry run produces a complete report from a bundled fixture, with no token and no network call at all, which is the honest way to evaluate something before granting it access to a client portal. The full measurement log, including every endpoint that refuses, 404s or returns inconsistent results, ships in the repo.
If you would rather hand the whole review to somebody else, portal audits are one of the things we run for agencies under their brand, and how seat types interact with admin permissions is worth reading before anyone touches a client's subscription.
Sources
Frequently Asked Questions
Can a HubSpot tool check whether its own token has write access?
No. HubSpot's token introspection endpoint works for OAuth access tokens but returns 400 for private app tokens, which is the type agencies actually create. There is no read-only endpoint that reports a private app's granted scopes, so any runtime self-check of permissions is not possible.
How can an agency verify a third-party HubSpot tool is genuinely read-only?
Read its source for write verbs. In a well-built tool every request passes through one function, so confirming it is read-only means checking that function refuses anything other than GET, and that a test enforces it. Closed-source tools cannot offer this assurance at all.
Which HubSpot scopes grant write access even when you only want to read?
Three commonly requested scopes have no read-only variant: content, which covers site pages, blog and marketing email; external_integrations.forms.access for forms; and behavioral_events.event_definitions.read_write for custom events. Granting any of them to a diagnostic gives that tool write access to the client portal.
Is a private app safer than connecting a marketplace app to a client portal?
A private app is easier to scope narrowly and easier to revoke, since it is created inside the portal and grants exactly the scopes selected. The safety comes from the scopes granted rather than the mechanism, so a private app with broad write scopes is no safer than any other integration.
What happens when a HubSpot API call is refused rather than empty?
HubSpot returns 403 with a generic message that does not name the missing scope. A careful tool reports that as unreadable rather than as zero, because treating a refusal as an absence produces confident findings about features the tool was never able to see.
Should agencies give clients access to the tools used in an audit?
Yes, where the tool is read-only and open source. Handing a client something they can inspect and run themselves turns a security conversation into a short one, and the diagnosis it produces still points at remediation work that needs a human to scope and deliver.
HubSpot Portal Audits
What's Hiding in Your Clients' Portals?
A meticulous white-label audit turns messy portals into a prioritized roadmap, and into your next retainer conversation.
Related Articles

Can a HubSpot Contact and Company Have Different Lifecycle Stages?
Yes. HubSpot contacts and companies share one lifecycle stage option set but hold independent values, unless a sync setting is overwriting them.

