<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Meticulosity</title>
    <description>The latest articles on DEV Community by Meticulosity (@meticulosity).</description>
    <link>https://dev.to/meticulosity</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F3847170%2F5818dbae-f979-4bdb-bf4f-bfcce103fe78.jpg</url>
      <title>DEV Community: Meticulosity</title>
      <link>https://dev.to/meticulosity</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/meticulosity"/>
    <language>en</language>
    <item>
      <title>Nine things I measured about the HubSpot API that the docs don't tell you</title>
      <dc:creator>Meticulosity</dc:creator>
      <pubDate>Mon, 03 Aug 2026 04:04:16 +0000</pubDate>
      <link>https://dev.to/meticulosity/nine-things-i-measured-about-the-hubspot-api-that-the-docs-dont-tell-you-2kbl</link>
      <guid>https://dev.to/meticulosity/nine-things-i-measured-about-the-hubspot-api-that-the-docs-dont-tell-you-2kbl</guid>
      <description>&lt;p&gt;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?&lt;/p&gt;

&lt;p&gt;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: &lt;a href="https://github.com/meticulosity/hubspot-license-fit" rel="noopener noreferrer"&gt;meticulosity/hubspot-license-fit&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  1. The API cannot tell you what a portal is subscribed to
&lt;/h2&gt;

&lt;p&gt;This is the constraint the whole design hangs off, so it is worth establishing first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /account-info/v3/details

{"portalId": 0000000, "accountType": "STANDARD", "timeZone": "...",
 "companyCurrency": "USD", "additionalCurrencies": ["CAD", "SAR"],
 "dataHostingLocation": "na1", "uiDomain": "app.hubspot.com"}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;No subscription object, no tier field, no entitlement list. &lt;code&gt;accountType&lt;/code&gt; describes the kind of account, not the tier purchased.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. You cannot introspect a private app token
&lt;/h2&gt;

&lt;p&gt;HubSpot documents &lt;code&gt;GET /oauth/v1/access-tokens/{token}&lt;/code&gt; for checking a token's scopes. It does not work for private app tokens, which is what everyone actually uses:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /oauth/v1/access-tokens/{pat-token}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;strong&gt;400&lt;/strong&gt; &lt;code&gt;The access token must have the correct format&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /oauth/v2/access-tokens/{pat-token}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;GET /oauth/v1/private-apps/access-tokens/{pat-token}&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;404&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;That endpoint is for OAuth access tokens. A &lt;code&gt;pat-na1-...&lt;/code&gt; token has no introspection path at all.&lt;/p&gt;

&lt;p&gt;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 &lt;em&gt;their&lt;/em&gt; configuration to &lt;em&gt;my&lt;/em&gt; code: one function that builds every request and refuses anything that is not a GET, plus a test that greps the source.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_request&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;None&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;method&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;GET&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="nc"&gt;ReadOnlyViolation&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
            &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;this tool issues GET requests only, refused: %s %s&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt; &lt;span class="o"&gt;%&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;method&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;WRITE_VERBS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;re&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;compile&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;r&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;]\s*(POST|PATCH|PUT|DELETE)\s*[&lt;/span&gt;&lt;span class="sh"&gt;"'&lt;/span&gt;&lt;span class="s"&gt;]&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;test_no_write_verb_appears_in_any_source_file&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
    &lt;span class="n"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[...]&lt;/span&gt;  &lt;span class="c1"&gt;# scan every .py in the package
&lt;/span&gt;    &lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;offenders&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. A 403 never tells you which scope is missing
&lt;/h2&gt;

&lt;p&gt;Four different scope failures, four identical response bodies:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"This app hasn't been granted all required scopes to make this call.
            Read more about required scopes here: https://developers.hubspot.com/scopes."&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"correlationId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"..."&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  4. A 403 and a zero are different answers, and conflating them is dangerous
&lt;/h2&gt;

&lt;p&gt;Obvious when written down, easy to get wrong in code. &lt;code&gt;GET /settings/v3/users/teams&lt;/code&gt; returned 403 on a portal that plainly had five teams.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;So the gate model has three states, and the third never renders as the second:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;FIRES&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;fires&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# something requires a tier
&lt;/span&gt;&lt;span class="n"&gt;CLEAR&lt;/span&gt;   &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;clear&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;    &lt;span class="c1"&gt;# read successfully, not present
&lt;/span&gt;&lt;span class="n"&gt;UNKNOWN&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;unknown&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;  &lt;span class="c1"&gt;# could not read, so nothing is claimed
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  5. The workflow list endpoint returned 0 flows, then 113
&lt;/h2&gt;

&lt;p&gt;Same token, seconds apart:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /automation/v4/flows  -&amp;gt;  {"results": []}
GET /automation/v4/flows  -&amp;gt;  {"results": [ ...113 flows... ]}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It paginates via &lt;code&gt;paging.next.after&lt;/code&gt; 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.&lt;/p&gt;

&lt;p&gt;Paginate, and retry an empty first page before believing it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;pages&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;page&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;retry_empty_first_page&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;retried_empty&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;retried_empty&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;
    &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;self&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pause&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;continue&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;GET /automation/v4/flows/{id}&lt;/code&gt; is reliable. Only the list misbehaves. (&lt;code&gt;/automation/v3/workflows&lt;/code&gt; returns a different, smaller set: 82 against 113. It is not a substitute.)&lt;/p&gt;

&lt;h2&gt;
  
  
  6. HubSpot ships its own calculated properties into every portal
&lt;/h2&gt;

&lt;p&gt;Calculated properties are a paid-tier feature, so "does any property carry a calculation formula?" looks like a reasonable gate check.&lt;/p&gt;

&lt;p&gt;It is not. On the portal I tested, 21 contact properties carried &lt;code&gt;calculationFormula&lt;/code&gt;, and &lt;strong&gt;all 21 had &lt;code&gt;hubspotDefined: true&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;days_to_close&lt;/code&gt; and friends ship everywhere, including free portals. User-created calculated properties: one.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;with_formula&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;calculationFormula&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="n"&gt;user_formula&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;with_formula&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;get&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;hubspotDefined&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Only the second list means anything. Without that filter the tool fires a paid-tier claim on every portal in existence.&lt;/p&gt;

&lt;h2&gt;
  
  
  7. Association labels have the same trap
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight http"&gt;&lt;code&gt;&lt;span class="err"&gt;GET /crm/v4/associations/contacts/companies/labels

[{"category": "HUBSPOT_DEFINED", "label": "Primary"},
 {"category": "HUBSPOT_DEFINED", "label": "Billing Contact"},
 {"category": "USER_DEFINED",    "label": "Renewal Owner"}]
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every portal ships the &lt;code&gt;HUBSPOT_DEFINED&lt;/code&gt; ones. Filter on &lt;code&gt;category == "USER_DEFINED"&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  8. Three refusals that can be routed around
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Teams: 403.&lt;/strong&gt; But &lt;code&gt;GET /crm/v3/owners&lt;/code&gt; returns a &lt;code&gt;teams&lt;/code&gt; array per owner, and &lt;code&gt;/settings/v3/users&lt;/code&gt; returns &lt;code&gt;primaryTeamId&lt;/code&gt; and &lt;code&gt;secondaryTeamIds&lt;/code&gt;. Five named teams were readable while the teams endpoint itself refused.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Business units: 404&lt;/strong&gt; on &lt;code&gt;/business-units/v3/user/&lt;/code&gt;. But &lt;code&gt;GET /marketing/v3/emails&lt;/code&gt; carries &lt;code&gt;businessUnitId&lt;/code&gt; per email, and six of 112 emails sat on a non-default unit. That answers the question the direct endpoint would not.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sequences: 400.&lt;/strong&gt; &lt;code&gt;GET /automation/v4/sequences&lt;/code&gt; returns &lt;code&gt;query param userId may not be null&lt;/code&gt;. Sequences are per user. Pass &lt;code&gt;?userId=N&lt;/code&gt; and sample users until one has some.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. Some HubSpot scopes have no read-only variant
&lt;/h2&gt;

&lt;p&gt;This one changed the product, not just the code.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;content&lt;/code&gt;, &lt;code&gt;external_integrations.forms.access&lt;/code&gt;, and &lt;code&gt;behavioral_events.event_definitions.read_write&lt;/code&gt; are all offered only as combined read-and-write scopes.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Same reasoning killed one more signal. &lt;code&gt;GET /crm/v3/lists/search&lt;/code&gt; returns &lt;strong&gt;405&lt;/strong&gt;: 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.&lt;/p&gt;

&lt;h2&gt;
  
  
  The thing I would tell myself at the start
&lt;/h2&gt;

&lt;p&gt;Write down which of your findings are observations and which are claims, and keep them in separate parts of the output.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;Full source, the fixtures, and the complete measurement log are in the repo: &lt;a href="https://github.com/meticulosity/hubspot-license-fit" rel="noopener noreferrer"&gt;meticulosity/hubspot-license-fit&lt;/a&gt;. It is Python with no dependencies, and &lt;code&gt;python3 -m license_fit --dry-run&lt;/code&gt; renders a full sample report without a token or a network call.&lt;/p&gt;

</description>
      <category>hubspot</category>
      <category>api</category>
      <category>python</category>
      <category>opensource</category>
    </item>
  </channel>
</rss>
