<?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>Your MCP server's real constraint is the context window, not the API</title>
      <dc:creator>Meticulosity</dc:creator>
      <pubDate>Tue, 04 Aug 2026 13:00:00 +0000</pubDate>
      <link>https://dev.to/meticulosity/your-mcp-servers-real-constraint-is-the-context-window-not-the-api-5gb9</link>
      <guid>https://dev.to/meticulosity/your-mcp-servers-real-constraint-is-the-context-window-not-the-api-5gb9</guid>
      <description>&lt;p&gt;We use AskElephant to record client calls, and we work in claude.ai. Those two things could not talk to each other, and the reason is structural rather than a missing feature.&lt;/p&gt;

&lt;p&gt;AskElephant ships an MCP server. It runs locally over stdio, which serves Claude Desktop, Cursor, VS Code and Windsurf. A browser cannot spawn a process on your laptop, so claude.ai needs an MCP server at an HTTPS address. That is a different program.&lt;/p&gt;

&lt;p&gt;Building it took about a day. Working out what it should refuse to do took considerably longer, and that part generalises to any MCP server sitting in front of a large corpus. The code is &lt;a href="https://github.com/meticulosity-dward/askelephant-mcp" rel="noopener noreferrer"&gt;on GitHub&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The arithmetic that determines the design
&lt;/h2&gt;

&lt;p&gt;Before writing tools, measure your payload. We measured every transcript in our account: 3,706 engagements, 2,230 of which carry one.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Characters&lt;/th&gt;
&lt;th&gt;Approx tokens&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Average transcript-bearing call&lt;/td&gt;
&lt;td&gt;32,485&lt;/td&gt;
&lt;td&gt;8,100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Median&lt;/td&gt;
&lt;td&gt;28,240&lt;/td&gt;
&lt;td&gt;7,060&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;p95&lt;/td&gt;
&lt;td&gt;75,543&lt;/td&gt;
&lt;td&gt;18,885&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Largest&lt;/td&gt;
&lt;td&gt;175,643&lt;/td&gt;
&lt;td&gt;43,900&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The largest single call is more than a fifth of a 200,000-token context window on its own.&lt;/p&gt;

&lt;p&gt;Now consider the actual user request: "find where we discussed pricing with this client this year." That touches maybe forty calls. The naive tool returns forty transcripts, which is roughly 325,000 tokens. It does not fit. Not slow, not costly: impossible.&lt;/p&gt;

&lt;p&gt;So the design constraint is not the API. It is arithmetic, and it arrives before you write a single tool definition.&lt;/p&gt;

&lt;h2&gt;
  
  
  Do the reading on the server
&lt;/h2&gt;

&lt;p&gt;The whole design collapses to one line:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;The Worker does the reading. Claude does the thinking.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;&lt;code&gt;search_transcripts&lt;/code&gt; fetches the candidate transcripts, scans them inside the Worker, and returns only matching passages with speaker and timestamp attached. A real forty-meeting search, run through claude.ai against the live archive, returned 76,757 characters of excerpt: about 19,200 tokens rather than 325,000.&lt;/p&gt;

&lt;p&gt;The scanner is a pure function with no IO, which makes it trivial to test:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;scanTranscript&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;text&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;queries&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;[],&lt;/span&gt;
  &lt;span class="nx"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ScanOptions&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{},&lt;/span&gt;
&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nx"&gt;Hit&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Transcript lines arrive as &lt;code&gt;[0:03:15] Speaker Name: utterance&lt;/code&gt;, so matching per line rather than per character means every hit carries its own timestamp and speaker for free. Excerpt windows snap outward to line boundaries, so an excerpt never begins mid-sentence.&lt;/p&gt;

&lt;p&gt;Two details that matter more than they look:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Match literally, never by regex.&lt;/strong&gt; Query terms come from a language model, and a model will happily emit &lt;code&gt;5.00&lt;/code&gt; or &lt;code&gt;C++&lt;/code&gt;. Building a &lt;code&gt;RegExp&lt;/code&gt; from that input is both a correctness bug and an injection hazard. A test pins it: &lt;code&gt;5.00&lt;/code&gt; must not match &lt;code&gt;5x00&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Keep offsets against the raw string.&lt;/strong&gt; The chunked transcript reader slices the cached text by the offsets the scanner produced. We later stripped carriage returns from excerpt output, and the only safe way to do that was in the returned text, never by normalising the source, because normalising shifts every index. The test asserts both that the excerpt is clean and that the offset still points into the unmodified string.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug worth stealing: limits multiply
&lt;/h2&gt;

&lt;p&gt;We had three limits, each defensible on its own:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;max_meetings&lt;/code&gt;: 40&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;max_hits_per_meeting&lt;/code&gt;: 10&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;context_chars&lt;/code&gt;: 2000&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Each is validated independently. Nothing validated their product, which measured at about 229,000 tokens. The caps existed to protect a 200,000-token window and their worst case overran it.&lt;/p&gt;

&lt;p&gt;Two things compounded it. Excerpts snap outward to line boundaries, so a request for 2,000 characters yields about 2,297. And there is no overlap dedup, so ten hits inside one dense passage return substantially the same text ten times.&lt;/p&gt;

&lt;p&gt;The fix is a single budget across the whole run, in config rather than scattered through the code:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="nx"&gt;searchOutputCharBudget&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;120000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;   &lt;span class="c1"&gt;// about 30,000 tokens&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Enforced with one accumulator, admitting hits one at a time, and stopping the fetch loop entirely once spent. Critically, the response says which happened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Scanned 12 meeting(s). Results were CUT FOR SIZE at the output budget.
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A caller must be able to tell a cut for size from a cut for relevance. They mean opposite things and look identical if you do not say.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug worth stealing more: silence is not absence
&lt;/h2&gt;

&lt;p&gt;Originally, a failed transcript fetch was logged and skipped. Nothing about the failure reached the return value.&lt;/p&gt;

&lt;p&gt;Play that forward. The API key gets rotated. All forty fetches 401. The tool answers:&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;Scanned 40 meeting(s). No matches. Try different phrasings in queries, or widen the date range.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;A person reading that concludes the topic was never discussed. That is worse than an error, because an error is obviously an error. This is a confident wrong answer with a suggestion attached.&lt;/p&gt;

&lt;p&gt;The fix is counting, and reporting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;scanned&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;failed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;40&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;no_transcript&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;and rendering "40 could not be read" instead of "No matches". If your tool can return an empty result for more than one reason, it has to say which reason.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five API behaviours that each cost us a bug
&lt;/h2&gt;

&lt;p&gt;AskElephant's v2 API is not publicly documented. Every one of these was found by probing, and most were found only because something already broke.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;0. Some transcripts contain the same call twice.&lt;/strong&gt; Listed first because it is the one that survived everything else. The &lt;code&gt;transcript&lt;/code&gt; field can hold the whole call, an attachment marker, then a character-for-character second copy:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;[0:00:25] Alice: Hi, Bob.
... the entire call ...
[0:29:08] Bob: See ya.
[Attachment eatt_01KYAH5JQ4C31VVE2PE797AP69: Bot Recording]
[0:00:25] Alice: Hi, Bob.
... the entire call again, identical ...
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Measured on the call that exposed it: 59,088 characters, opening line recurring at offset 29,575, second segment identical across all 29,513 of its characters. The reliable signature is a &lt;strong&gt;backward jump in timestamps&lt;/strong&gt; — the transcript runs to 0:29:08 and the next line is 0:00:25.&lt;/p&gt;

&lt;p&gt;Search was therefore finding every match twice, paying the output budget for each passage twice, and filling the per-meeting hit cap with duplicates. The fix drops a segment that exactly repeats one already kept, and preserves near-duplicates and genuinely different second recordings, because losing a real utterance is far worse than wasting characters.&lt;/p&gt;

&lt;p&gt;There is a second, sharper lesson in how we sized it. The first measurement said half the corpus was affected. It was taken over the transcripts the Worker had cached — which are the meetings our searches happened to touch, skewing recent, internal and bot-recorded, exactly the population that duplicates. &lt;strong&gt;Measuring your cache measures your query history.&lt;/strong&gt; Re-measuring all 2,230 transcripts put it at 5.8%, about a million wasted tokens archive-wide.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;1. The auth header takes the raw key, no &lt;code&gt;Bearer&lt;/code&gt; prefix.&lt;/strong&gt; Cheap to fix, easy to lose an hour to.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;2. &lt;code&gt;processing_status&lt;/code&gt; is &lt;code&gt;PENDING&lt;/code&gt; on every record ever created&lt;/strong&gt;, including calls from 2023. We had built the initial filter around &lt;code&gt;COMPLETED&lt;/code&gt;, which returns zero rows for everything. Transcript availability actually tracks &lt;code&gt;engagement_type&lt;/code&gt;: calendar entries never carry one.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;3. &lt;code&gt;search&lt;/code&gt; matches titles only, never transcript bodies.&lt;/strong&gt; This is the reason the excerpt scanner exists at all. We got this wrong twice. First we assumed it searched bodies. Then we "verified" titles-only using the words &lt;code&gt;pricing&lt;/code&gt;, &lt;code&gt;leadership&lt;/code&gt; and &lt;code&gt;onboarding&lt;/code&gt;, which are exactly the words that also appear in meeting titles, so the evidence was consistent with both behaviours and distinguished neither. The proof that worked was a phrase spoken mid-call that appears nowhere in the title, run against a 2023 call so that index lag could not explain a miss.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;4. Filters need operators, and dates need full UTC.&lt;/strong&gt; &lt;code&gt;filter[company_ids]=&amp;lt;id&amp;gt;&lt;/code&gt; returns 400; it needs &lt;code&gt;filter[company_ids][in]&lt;/code&gt;. And &lt;code&gt;filter[start_at][gte]=2026-01-01&lt;/code&gt; returns 400 too:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;must be an ISO8601 UTC date (e.g. 2025-01-01T00:00:00.000Z)
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Both shapes came from our own earlier notes and neither had been sent to the live API. The tool now widens a bare &lt;code&gt;YYYY-MM-DD&lt;/code&gt; itself, to start of day for a lower bound and end of day for an upper bound, because a model asked for "this year" will emit the plain form every time.&lt;/p&gt;

&lt;p&gt;The pattern across all five: the plan described the API, and nobody had asked the API. If you take one process change from this post, make it a step that probes every request shape you actually send, once, before you trust it — and then a second step that runs the finished thing against real data, because number 0 passed every review gate and fell out of the first real question a human asked.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Cloudflare gave us
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;@cloudflare/workers-oauth-provider&lt;/code&gt; handles the part everyone gets stuck on. Claude.ai initiates OAuth 2.1 with Dynamic Client Registration, and this library makes the Worker its own OAuth server while delegating the actual login upstream, in our case to Cloudflare Access over OIDC with PKCE.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;default&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;OAuthProvider&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
  &lt;span class="na"&gt;apiRoute&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;apiHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;McpAskElephant&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;serve&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/mcp&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="na"&gt;defaultHandler&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;accessHandler&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="na"&gt;authorizeEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/authorize&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;tokenEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/token&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="na"&gt;clientRegistrationEndpoint&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;/register&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;McpAgent&lt;/code&gt; handles Streamable HTTP and session state on a Durable Object. KV caches transcripts, which are immutable once processed, with a TTL, because a cache without one is a permanent second copy of client data living outside the vendor's retention controls.&lt;/p&gt;

&lt;p&gt;One operational trap worth knowing. Each scanned meeting costs about three subrequests, so the default of 25 meetings costs roughly 76. The free plan caps at 50, so a free-tier deployment breaks at about 15 meetings, not at the 40 the config implies. The paid plan allows 1,000.&lt;/p&gt;

&lt;h2&gt;
  
  
  The security thing I got wrong in public
&lt;/h2&gt;

&lt;p&gt;We first restricted access by email domain, reasoning that revocation could be delegated upstream: everyone uses a company-controlled Claude account, so deprovisioning that account takes the connector with it.&lt;/p&gt;

&lt;p&gt;That reasoning is wrong, and we proved it by accident. The connector can be attached to &lt;strong&gt;any&lt;/strong&gt; Claude account, including a personal one, because the only identity the Worker ever sees is the Cloudflare Access identity. Our own testing left two separate Claude clients registered against one address.&lt;/p&gt;

&lt;p&gt;Access is now an explicit list of named individuals. And because removing someone from that list only stops them getting a &lt;em&gt;new&lt;/em&gt; grant, there is a script that deletes an existing one, since a grant in KV outlives any config change until it expires.&lt;/p&gt;

&lt;p&gt;If you are building anything similar: work out what your revocation story actually is, then test it, rather than reasoning about it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Worth saying about the process
&lt;/h2&gt;

&lt;p&gt;This was built with Claude Code, task by task, with a separate review pass on each one that had no attachment to the code it was reviewing.&lt;/p&gt;

&lt;p&gt;The interesting part is where the defects came from. Almost all of the real ones originated in the &lt;em&gt;plan&lt;/em&gt;, not the implementation: the filter shape, the date format, a row limit applied per search term so a two-term query could return double what was asked for, and a hand-written type for a contact's email field that disagreed with the API while agreeing perfectly with the test fixture invented alongside it.&lt;/p&gt;

&lt;p&gt;The implementations were mostly faithful. The specification was confidently wrong in four places. That ratio is the argument for adversarial review, and it is not the argument the tooling vendors usually make.&lt;/p&gt;

&lt;p&gt;Repo, with setup instructions for your own deployment: &lt;a href="https://github.com/meticulosity-dward/askelephant-mcp" rel="noopener noreferrer"&gt;github.com/meticulosity-dward/askelephant-mcp&lt;/a&gt;&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>cloudflare</category>
      <category>typescript</category>
      <category>ai</category>
    </item>
    <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>
