<?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: Steven Carleton</title>
    <description>The latest articles on DEV Community by Steven Carleton (@cblu2005).</description>
    <link>https://dev.to/cblu2005</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%2F4074919%2F35281dab-e9e3-4dc7-b6bf-06bf1466e365.png</url>
      <title>DEV Community: Steven Carleton</title>
      <link>https://dev.to/cblu2005</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/cblu2005"/>
    <language>en</language>
    <item>
      <title>How I turned my government-data Actors into MCP tools Claude calls mid-task</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Fri, 04 Sep 2026 14:07:25 +0000</pubDate>
      <link>https://dev.to/apify/how-i-turned-my-government-data-actors-into-mcp-tools-claude-calls-mid-task-2bb9</link>
      <guid>https://dev.to/apify/how-i-turned-my-government-data-actors-into-mcp-tools-claude-calls-mid-task-2bb9</guid>
      <description>&lt;p&gt;In July 2026 I built a portfolio of pay-per-result Apify Actors around US government data: SAM.gov federal contract opportunities, the National Provider Identifier (NPI) registry of healthcare providers, building permits from 10 city open-data portals, Florida contractor licenses.&lt;/p&gt;

&lt;p&gt;Then I asked Claude a question I'd normally answer with my own Actor: "find active cybersecurity solicitations set aside for small business." I watched it flail. It knew SAM.gov existed. It knew the data was public. It could not reach any of it. The official API wants a registered key. The search UI is not an API. The daily CSV extract runs roughly 240 MB, which is not a thing you hand a chat model mid-conversation.&lt;/p&gt;

&lt;p&gt;That gap, an agent that knows &lt;em&gt;where&lt;/em&gt; the answer lives but can't &lt;em&gt;reach&lt;/em&gt; it, is what an Actor plus the Model Context Protocol (MCP) closes. MCP is the open standard that lets AI clients like Claude and Cursor call external tools. This article covers how I exposed my Actors to agents, the schema decisions that made the calls reliable, and the code from the small open-source MCP server I shipped: &lt;a href="https://github.com/CBLU2005/us-govdata-mcp" rel="noopener noreferrer"&gt;us-govdata-mcp&lt;/a&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prerequisites
&lt;/h2&gt;

&lt;p&gt;If you want to follow along and wrap your own Actor:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Node.js 18 or newer (the server uses the built-in &lt;code&gt;fetch&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;An Apify account and API token (free plan works).&lt;/li&gt;
&lt;li&gt;A published Actor (ideally pay-per-result, so agent calls map cleanly to charges).&lt;/li&gt;
&lt;li&gt;Any MCP client: Claude Desktop, Claude Code, or Cursor.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Why government data is hostile territory for agents
&lt;/h2&gt;

&lt;p&gt;Every source my Actors wrap is officially public. Not one of them is usable by an agent out of the box. The failure modes are worth walking through one by one, because each of them shaped a design decision later.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://sam.gov" rel="noopener noreferrer"&gt;SAM.gov&lt;/a&gt;&lt;/strong&gt; publishes every federal contract opportunity. The documented API needs a registered key with rate limits. Type "sam.gov api" into Google and autocomplete finishes the sentence for you: "api key," "api limits," "rate limit." My &lt;a href="https://apify.com/cblu/sam-gov-contract-opportunities-scraper" rel="noopener noreferrer"&gt;SAM.gov Actor&lt;/a&gt; uses the site's own public search backend instead (no key) and enriches each notice with contracting-officer emails and phones from the detail endpoint. To be clear about what that means: this is public government-records data, requested at polite rates (the same calls the sam.gov site makes for any visitor, minus the clicking).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The NPI registry (NPPES)&lt;/strong&gt;, run by the Centers for Medicare &amp;amp; Medicaid Services, has a &lt;a href="https://npiregistry.cms.hhs.gov" rel="noopener noreferrer"&gt;free API&lt;/a&gt;. It also silently caps any search at 1,200 records and then repeats its last page forever. Run the numbers: 1,200 records out of a registry of 8 million-plus is 0.015% of the data, served up as if it were everything. A human notices the duplicated results eventually. An agent takes the cap at face value and hands its user "1,200 dentists in Miami" with total confidence. My &lt;a href="https://apify.com/cblu/npi-healthcare-providers-scraper" rel="noopener noreferrer"&gt;NPI Actor&lt;/a&gt; detects the cap and fans the query out by ZIP prefix automatically.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Florida's DBPR&lt;/strong&gt; (Department of Business and Professional Regulation) publishes the full state license roll as CSV extracts, behind a content delivery network (CDN) that returns 403 to datacenter IPs. That one is quietly lethal. The agent's compute &lt;em&gt;is&lt;/em&gt; a datacenter IP. The data is public and the front door is closed to the very callers we're discussing. (What those files did to my Actor once the connection was open is a story of its own; I told it in &lt;a href="https://dev.to/apify/my-actor-worked-for-humans-and-failed-for-agents-a-bug-postmortem-in-four-acts-3o81"&gt;Tuesday's postmortem&lt;/a&gt;.)&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;City permit portals&lt;/strong&gt; are 10 different schemas across 10 cities. Nothing an agent can learn once and reuse. Flattening that into one record shape is the whole reason my &lt;a href="https://apify.com/cblu/us-building-permits-scraper" rel="noopener noreferrer"&gt;permits Actor&lt;/a&gt; exists.&lt;/p&gt;

&lt;p&gt;The pattern is consistent: public data, real engineering tax. An Actor pays that tax once, on my side. The open question is how an agent finds and calls it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The zero-effort path: Apify's own MCP server
&lt;/h2&gt;

&lt;p&gt;Here's the part that cost me nothing: every published Actor is already callable by agents through &lt;a href="https://docs.apify.com/platform/integrations/mcp" rel="noopener noreferrer"&gt;Apify's MCP server&lt;/a&gt; at &lt;a href="https://mcp.apify.com" rel="noopener noreferrer"&gt;mcp.apify.com&lt;/a&gt;. An agent connected to it can discover Actors in the store and run them with the user's Apify token. When I published my Actors, I got an agent-facing API for free.&lt;/p&gt;

&lt;p&gt;That matters for prioritization. If you have a published Actor and 30 spare seconds, you already have an MCP story. Your store listing title, description, and input schema become your tool documentation. (That realization sent me back to rewrite all of mine. More on schema design below.)&lt;/p&gt;

&lt;p&gt;So why did I build a dedicated server anyway? Three reasons.&lt;/p&gt;

&lt;p&gt;First, &lt;strong&gt;curation&lt;/strong&gt;. A generic gateway offers an agent thousands of Actors. I wanted 3 named tools with tight descriptions, so a client configured with my server does exactly one job: search US government data.&lt;/p&gt;

&lt;p&gt;Second, &lt;strong&gt;guardrails&lt;/strong&gt;. My server sets defaults an agent won't think to set, like capping results at 25 per call so an exploratory question costs cents, not dollars.&lt;/p&gt;

&lt;p&gt;Third, &lt;strong&gt;distribution&lt;/strong&gt;. The server is an MIT-licensed repo with its own README, &lt;a href="https://glama.ai/mcp/servers/@CBLU2005/us-govdata-mcp" rel="noopener noreferrer"&gt;listed on the Glama MCP directory&lt;/a&gt;. Follow the money for a second: users bring their own Apify token, every tool call runs my Actors as a paid run on their own account, I never see their data, and I keep the per-result revenue. It's a funnel with the incentives printed on the outside.&lt;/p&gt;

&lt;h2&gt;
  
  
  The server: 800 lines, 3 tools, no state
&lt;/h2&gt;

&lt;p&gt;The whole server is just over 800 lines of TypeScript across 4 files. Each tool validates its arguments with zod, builds the Actor input, and calls one Apify endpoint: &lt;a href="https://docs.apify.com/api/v2/act-run-sync-get-dataset-items-post" rel="noopener noreferrer"&gt;&lt;code&gt;run-sync-get-dataset-items&lt;/code&gt;&lt;/a&gt;. That endpoint starts the Actor, waits for it to finish, and returns the dataset items in one response, the right shape for a synchronous tool call. This is very simple, and it's supposed to be.&lt;/p&gt;

&lt;p&gt;The core is small enough to show almost whole. This is the real client from &lt;code&gt;src/apify.ts&lt;/code&gt; (trimmed of the dry-run branch and the ACTORS table):&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;APIFY_API_BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;https://api.apify.com/v2&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;class&lt;/span&gt; &lt;span class="nc"&gt;ApifyClientError&lt;/span&gt; &lt;span class="kd"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;{}&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;runActor&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="nx"&gt;actorKey&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;keyof&lt;/span&gt; &lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;ACTORS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nl"&gt;timeoutSecs&lt;/span&gt;&lt;span class="p"&gt;?:&lt;/span&gt; &lt;span class="kr"&gt;number&lt;/span&gt; &lt;span class="p"&gt;}&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="nb"&gt;Promise&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;items&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;[]&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;actor&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;ACTORS&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;actorKey&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;timeoutSecs&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;options&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;timeoutSecs&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt;
    &lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;APIFY_API_BASE&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/acts/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;apiId&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/run-sync-get-dataset-items`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`?clean=true&amp;amp;format=json&amp;amp;timeout=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;timeoutSecs&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;token&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;process&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;env&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;APIFY_TOKEN&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;trim&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;token&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;missingTokenMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;AbortController&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;killer&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;setTimeout&lt;/span&gt;&lt;span class="p"&gt;(()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;abort&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;timeoutSecs&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;let&lt;/span&gt; &lt;span class="na"&gt;res&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;try&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nx"&gt;res&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
      &lt;span class="na"&gt;method&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;POST&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/json&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="na"&gt;Authorization&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`Bearer &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;token&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="p"&gt;},&lt;/span&gt;
      &lt;span class="na"&gt;body&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;JSON&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stringify&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;input&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;signal&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;controller&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;signal&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;span class="k"&gt;catch &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="s2"&gt;`Could not reach the Apify API (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt; &lt;span class="k"&gt;instanceof&lt;/span&gt; &lt;span class="nb"&gt;Error&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;). `&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
        &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Check your network connection and try again.&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;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;finally&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="nf"&gt;clearTimeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;killer&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="p"&gt;}&lt;/span&gt;

  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="c1"&gt;// Status-specific messages (see "Errors an agent can act on" below).&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Apify API error (HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&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="p"&gt;}&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="nx"&gt;unknown&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nb"&gt;Array&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isArray&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;ApifyClientError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Unexpected response from the Apify API (expected a JSON array of dataset items).&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;span class="p"&gt;}&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;items&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;Details that earn their keep:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The token travels in an &lt;code&gt;Authorization: Bearer&lt;/code&gt; header, never in the URL. URLs end up in logs.&lt;/li&gt;
&lt;li&gt;The abort timer runs 30 seconds past the Apify-side timeout. Belt and suspenders against a hung socket.&lt;/li&gt;
&lt;li&gt;The response is checked for being an actual array before it's handed to the agent.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The server speaks two transports: stdio (what desktop clients spawn) and Streamable HTTP with a &lt;code&gt;/healthz&lt;/code&gt; endpoint. Adding HTTP cost one ~50-line Express handler (stateless, a fresh server per request) and made the Docker deployment story real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Schema design: writing for a reader that never skims
&lt;/h2&gt;

&lt;p&gt;Here's the mental shift that took me longest, and I'll admit I only half understood it at first. A human user of my Actor sees a form in Apify Console, rendered from the input schema, and pokes at it until the output looks right. An agent gets one shot. It reads the JSON schema, constructs a call, and whatever happens next is my fault or my credit.&lt;/p&gt;

&lt;p&gt;As such, every parameter description in the MCP server is written like documentation for a very literal junior developer. This is the real schema for the SAM.gov tool, abbreviated to the instructive parts:&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
  &lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;regex&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/^&lt;/span&gt;&lt;span class="se"&gt;\d{4}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d{2}&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Use YYYY-MM-DD format&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;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Date in YYYY-MM-DD format&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="nl"&gt;inputSchema&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;keyword&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Case-insensitive match against notice title, solicitation number, and description, &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;e.g. 'janitorial', 'cybersecurity', 'drone'.&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;span class="na"&gt;naicsCodes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;NAICS industry codes; prefixes work, e.g. '541511' or just '54' for all professional services.&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;span class="na"&gt;setAsides&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;()).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Small-business set-aside codes or label text, e.g. 'SBA' (total small business), &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;'8A', 'WOSB', 'SDVOSBC', 'HZC' (HUBZone).&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;span class="na"&gt;popStates&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;array&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;string&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;length&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)).&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Two-letter place-of-performance state codes, e.g. ['TX', 'FL'].&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;span class="na"&gt;postedAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;optional&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Only notices posted on/after this date (YYYY-MM-DD). Strongly recommended ‚Äî makes runs much faster.&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;span class="na"&gt;maxResults&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;z&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;number&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;int&lt;/span&gt;&lt;span class="p"&gt;().&lt;/span&gt;&lt;span class="nf"&gt;min&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;max&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="k"&gt;default&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;describe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Max opportunities to return (1-5000, default 25). Each returned record is billed at &lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
      &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;$0.004 per opportunity.&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;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The rules I converged on:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Every description carries examples.&lt;/strong&gt; Not "a North American Industry Classification System (NAICS) code" but "'541511' or just '54'". Agents pattern-match on examples far more reliably than on prose. The set-aside field lists the actual codes because an agent asked for "HUBZone contracts" needs the mapping to 'HZC' right there.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Enums over free text wherever the input space is closed.&lt;/strong&gt; The permits tool takes cities as &lt;code&gt;z.enum([...])&lt;/code&gt; with 11 literal values, the 10 supported sources plus &lt;code&gt;"all"&lt;/code&gt;. An agent physically cannot ask for a city I don't support. In the Actor's own input schema, the same idea shows up as &lt;code&gt;enum&lt;/code&gt; plus &lt;code&gt;enumTitles&lt;/code&gt;: closed inputs get a fixed list of values with human-readable labels.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dates are regex-validated with a corrective error message.&lt;/strong&gt; &lt;code&gt;"Use YYYY-MM-DD format"&lt;/code&gt; comes back to the agent on a bad date. It self-corrects on the next attempt. That corrective loop is the cheapest robustness you can buy.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Defaults are cost guardrails, not conveniences.&lt;/strong&gt; Every tool defaults to 25 results. The Actors themselves default to 500, the right number for a human building a lead list and the wrong number for an agent answering "are there any solar permits in Austin?" So I did the arithmetic on what a default question should cost: 25 permits at $0.005 apiece is about $0.13, 25 contract notices ‚âà $0.10, 25 providers ‚âà $0.05. An exploratory question costs a dime. Nobody has to stop and think about a dime.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Unset optionals get stripped.&lt;/strong&gt; A tiny helper removes &lt;code&gt;undefined&lt;/code&gt; values so the Actor never receives &lt;code&gt;"minValuation": undefined&lt;/code&gt;, which JSON-stringifies away silently in some paths and lands as a literal in others:&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="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;compact&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt; &lt;span class="nb"&gt;Record&lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&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;unknown&lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;fromEntries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="nb"&gt;Object&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;entries&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;obj&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(([,&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="nx"&gt;v&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="kc"&gt;undefined&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;&lt;strong&gt;The price is in the tool description itself.&lt;/strong&gt; Each description ends with: runs the paid Actor at this store URL, on YOUR account, pay per result, charged only for records actually returned. That's partly ethics. The agent's user should never be surprised by a charge. It's also plain self-interest, because an agent that understands the pricing sets sane &lt;code&gt;maxResults&lt;/code&gt; values. My smoke test literally asserts the disclosure exists:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;assert&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;ok&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="sr"&gt;/pay per result/i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;test&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&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="nx"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; description discloses pay-per-result pricing`&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;h2&gt;
  
  
  Errors an agent can act on
&lt;/h2&gt;

&lt;p&gt;My first instinct was to let errors throw and bubble up as protocol-level failures. That was wrong. A thrown error gives the agent a stack trace and nothing to do. MCP lets a tool return &lt;code&gt;isError: true&lt;/code&gt; with text content instead. And the agent &lt;em&gt;reads that text&lt;/em&gt;.&lt;/p&gt;

&lt;p&gt;So every failure mode returns instructions:&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;missingTokenMessage&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;ActorInfo&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="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;APIFY_TOKEN is not set, so this tool cannot run yet. Setup takes ~2 minutes:&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`1. Create a free Apify account: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;SIGN_UP_URL&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="s2"&gt;`2. Copy your API token: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;TOKEN_URL&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="s2"&gt;`3. Set APIFY_TOKEN in this MCP server's environment (see the README's client config examples) and restart your MCP client.`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="dl"&gt;""&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`Note: this tool runs the paid Apify actor at &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;storeUrl&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="s2"&gt;`(pay per result: &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;pricing&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; ‚Äî you are only charged for records actually returned,`&lt;/span&gt;
      &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt; and Apify's free plan includes monthly platform credit to start with).&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;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&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;When a user installs the server without a token, the first tool call doesn't crash. Claude relays a numbered setup guide with the sign-up link. The error message is onboarding.&lt;/p&gt;

&lt;p&gt;The HTTP error paths get the same treatment. A 401 says "double-check APIFY_TOKEN" and links where to copy a fresh one. A 402 or 403 explains the account is likely out of platform credit and links the billing page and the Actor's pricing. A 404 says the Actor may have been renamed and links the store page. Each message answers the question the agent will be asked next: "so what do I tell the user?"&lt;/p&gt;

&lt;p&gt;One deliberate choice: the server starts and lists its tools &lt;em&gt;without&lt;/em&gt; a token. The token check happens at call time. A client shouldn't fail to boot because one server of five is unconfigured.&lt;/p&gt;

&lt;h2&gt;
  
  
  Dry-run mode: testing the agent path without spending anything
&lt;/h2&gt;

&lt;p&gt;The hardest part of testing agent tooling is that real calls cost real money on someone's account. My answer is an &lt;code&gt;APIFY_DRY_RUN=1&lt;/code&gt; environment variable. In dry-run mode, a tool call makes no network request. It returns canned sample records (each flagged &lt;code&gt;_dryRun: true&lt;/code&gt;) plus the exact Apify API request that &lt;em&gt;would&lt;/em&gt; have been sent:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"resultCount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"results"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"noticeId"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"dry0000000000000000000000000001"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"title"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Cybersecurity Assessment and Continuous Monitoring Services (SAMPLE)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"setAside"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Total Small Business Set-Aside (FAR 19.5)"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"naicsCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"541512"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"responseDeadline"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-31T17:00:00-04:00"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"_dryRun"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"dryRun"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"apifyRequest"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"POST"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"url"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"https://api.apify.com/v2/acts/cblu~sam-gov-contract-opportunities-scraper/run-sync-get-dataset-items?clean=true&amp;amp;format=json&amp;amp;timeout=300"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"headers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Content-Type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"application/json"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"Authorization"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Bearer &amp;lt;APIFY_TOKEN&amp;gt;"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"body"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"keyword"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"cybersecurity"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"naicsCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"541512"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"popStates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TX"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"activeOnly"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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;(For space, the sample record is cut to its instructive fields; the real payload also carries department, office, place of performance, and the full description.)&lt;/p&gt;

&lt;p&gt;This one feature pays for itself three ways. The test suite runs the entire path (server boot, tool discovery, argument validation, input construction, both transports) with no Apify account and no network. Continuous integration (CI) stays free and deterministic. And anyone evaluating the server can point Claude at it and watch real tool calls happen, with an explicit note in the payload telling the agent these are samples.&lt;/p&gt;

&lt;p&gt;The smoke test asserts the constructed request byte-for-byte: right endpoint, &lt;code&gt;clean=true&lt;/code&gt;, bearer header, defaults applied, unset optionals absent. When I run &lt;code&gt;npm test&lt;/code&gt;, every check passes or the build doesn't ship.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight console"&gt;&lt;code&gt;&lt;span class="gp"&gt;$&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;npm &lt;span class="nb"&gt;test&lt;/span&gt;
&lt;span class="go"&gt;
[1] stdio introspection without APIFY_TOKEN
us-govdata-mcp v0.1.0 running on stdio
note: APIFY_TOKEN is not set. Tool listing works, but tool calls will fail until you set it. Get a free token at https://console.apify.com/sign-up
  PASS  tools/list returns 3 tools: search_building_permits, search_federal_contract_opportunities, search_healthcare_providers
  PASS  every tool has a JSON Schema input and a pricing disclosure in its description

[2] tool call without APIFY_TOKEN returns a helpful error
  PASS  error explains the missing token, links sign-up + actor store page, states the price

[3] APIFY_DRY_RUN=1 exercises the full tool path with canned data
us-govdata-mcp v0.1.0 running on stdio
note: APIFY_DRY_RUN is set ‚Äî tools return canned sample data (no Apify calls, no charges).
&lt;/span&gt;&lt;span class="gp"&gt;  PASS  search_building_permits: 2 sample record(s);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;request construction verified
&lt;span class="gp"&gt;  PASS  search_federal_contract_opportunities: 1 sample record(s);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;request construction verified
&lt;span class="gp"&gt;  PASS  search_healthcare_providers: 2 sample record(s);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;request construction verified
&lt;span class="go"&gt;
[4] Streamable HTTP transport introspection
  PASS  GET /healthz responds
  PASS  tools/list over Streamable HTTP returns the same 3 tools
  PASS  dry-run tool call works over Streamable HTTP

All checks passed (9).
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  What it looks like when an agent uses it
&lt;/h2&gt;

&lt;p&gt;With the server configured in Claude Desktop:&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="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"mcpServers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"us-govdata"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"command"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"npx"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"args"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"-y"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"github:CBLU2005/us-govdata-mcp"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
      &lt;/span&gt;&lt;span class="nl"&gt;"env"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"APIFY_TOKEN"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"your_apify_token_here"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&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;‚Ä¶the prompts that used to require a browser session become tool calls:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;em&gt;"Find building permits for new construction over $500k issued in Austin this month, with contractor names."&lt;/em&gt; ‚Üí &lt;code&gt;search_building_permits&lt;/code&gt; with &lt;code&gt;cities: ["austin"]&lt;/code&gt;, &lt;code&gt;issuedAfter&lt;/code&gt;, &lt;code&gt;minValuation: 500000&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"Any active small-business set-aside cybersecurity solicitations due after today? Include the contracting officer's email."&lt;/em&gt; ‚Üí &lt;code&gt;search_federal_contract_opportunities&lt;/code&gt; with &lt;code&gt;keyword&lt;/code&gt;, &lt;code&gt;setAsides&lt;/code&gt;, &lt;code&gt;responseDueAfter&lt;/code&gt;, and the emails come back on the records.&lt;/li&gt;
&lt;li&gt;
&lt;em&gt;"List dentists in the greater Miami area with practice phone numbers."&lt;/em&gt; ‚Üí &lt;code&gt;search_healthcare_providers&lt;/code&gt; with &lt;code&gt;taxonomyDescription: "Dentist"&lt;/code&gt;, &lt;code&gt;postalCode: "331*"&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The wildcard in that last call is worth a sentence. The NPI Actor supports ZIP prefix wildcards precisely because "greater Miami" is not a ZIP code. The schema description spells out the pattern, &lt;code&gt;'331*' matches all ZIPs starting 331 (greater Miami)&lt;/code&gt;, and agents use it correctly because the example &lt;em&gt;is&lt;/em&gt; their use case.&lt;/p&gt;

&lt;p&gt;What I can honestly claim: the tool path is verified end to end by the test suite, the server is live on the Glama directory, and agent-originated runs land as ordinary paid runs on the calling user's account. What I can't claim: reliable telemetry on how often third-party agents call it. Apify shows me user and run counts per Actor, not per channel. Glama turned out to know more than I did. I claimed my listing while writing this, in late August 2026, and found a funnel waiting: 966 search impressions and 524 profile views in the trailing 30 days. And zero tool calls. Call it 500 developers opening the page and not one of them installing it.&lt;/p&gt;

&lt;p&gt;That is a humbling number and a more useful one than the dashboard I assumed didn't exist. My distribution problem was never discovery. It is the account-and-token wall standing between a curious developer and their first call.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd tell you to do differently
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Start with the Apify MCP server, not a custom one.&lt;/strong&gt; Publishing a well-schema'd Actor gets you agent reachability today. Build a dedicated server only when you want curation, defaults, or a distribution funnel you control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Descriptions and defaults are where the leverage is.&lt;/strong&gt; The transports took an afternoon. The schema descriptions took longer and matter more. Every hour spent adding examples to a field description repays itself in calls that don't fail.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Put prices where the agent can read them.&lt;/strong&gt; Pay-per-result plus an honest description is a genuinely good interface between a user's budget and an agent's enthusiasm.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the dry-run first.&lt;/strong&gt; I built it for CI and it turned out to be the demo, the docs, and the debugger.&lt;/p&gt;

&lt;h2&gt;
  
  
  Next steps
&lt;/h2&gt;

&lt;p&gt;The repo is MIT-licensed at &lt;a href="https://github.com/CBLU2005/us-govdata-mcp" rel="noopener noreferrer"&gt;github.com/CBLU2005/us-govdata-mcp&lt;/a&gt;. Clone it, swap in your own Actor IDs, and you have a dedicated MCP server for your own portfolio in an evening. &lt;code&gt;npm test&lt;/code&gt; proves the whole path with no account and no charges.&lt;/p&gt;

&lt;p&gt;My own roadmap: expose the Florida license Actor's new monitoring mode as a fourth tool, so an agent can set up standing license-compliance alerts instead of one-off searches. And directory listings (mcp.so, PulseMCP) to test whether MCP directories drive measurable installs.&lt;/p&gt;

&lt;p&gt;If you publish an Actor: your next thousand users may never see your store page. They'll be agents. Write your schemas like it.&lt;/p&gt;

</description>
      <category>tutorial</category>
      <category>javascript</category>
      <category>automation</category>
      <category>scraping</category>
    </item>
    <item>
      <title>My Actor worked for humans and failed for agents: a bug postmortem in four acts</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Tue, 01 Sep 2026 18:06:37 +0000</pubDate>
      <link>https://dev.to/apify/my-actor-worked-for-humans-and-failed-for-agents-a-bug-postmortem-in-four-acts-3o81</link>
      <guid>https://dev.to/apify/my-actor-worked-for-humans-and-failed-for-agents-a-bug-postmortem-in-four-acts-3o81</guid>
      <description>&lt;p&gt;On August 12, 2026, I looked at the public stats for my &lt;a href="https://apify.com/cblu/sam-gov-contract-opportunities-scraper" rel="noopener noreferrer"&gt;SAM.gov Contract Opportunities Scraper&lt;/a&gt; (an Apify Actor that searches US federal contract notices) and saw 22 failed runs in the last 30 days. My own runs? 7 lifetime, all green. My local test harness? Passing for weeks.&lt;/p&gt;

&lt;p&gt;The Actor had been quietly broken since late July. The platform's health checks had been failing daily since roughly July 21, and I hadn't looked. Meanwhile my &lt;a href="https://apify.com/cblu/florida-license-records-scraper" rel="noopener noreferrer"&gt;Florida license Actor&lt;/a&gt;, the only Actor in my portfolio earning money, had failed 9 of its 48 runs in 30 days while its daily health check kept passing. Sit with that ratio for a second: the check that ran every day was green, and one run in five was not.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ewu40m9g49sc2svjkda.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F6ewu40m9g49sc2svjkda.png" alt="Apify Console Insights: the SAM.gov Actor's daily run success rate in August 2026 — 0% on August 6, back to 100% after the August 12 fix" width="800" height="204"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;A note if you click that listing today: its 30-day stats still show the failures from this incident. All 12 of them are from a single day, August 6, on the build this article is about. Every run since the August 12 fix has succeeded, including one from a stranger this morning. The number rolls back on its own as the window moves, which is its own small lesson about what public quality metrics actually measure.&lt;/p&gt;

&lt;p&gt;Both Actors "worked" in every test I ran. Both were failing the callers that mattered. And increasingly, those callers are not humans clicking around Apify Console. They're AI agents hitting the Actor through an API, whether via the Model Context Protocol (MCP), Apify's MCP server, or my own &lt;a href="https://github.com/CBLU2005/us-govdata-mcp" rel="noopener noreferrer"&gt;us-govdata-mcp&lt;/a&gt; wrapper. Agents don't retry creatively. They don't eyeball suspicious output. They trust your schema, your pricing, and your exit code.&lt;/p&gt;

&lt;p&gt;Here is what broke, what I changed, and what I'd do differently. Every build number, run ID, and dollar amount below is real.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act one: the dataset schema that lied
&lt;/h2&gt;

&lt;p&gt;My SAM.gov Actor declares a &lt;a href="https://docs.apify.com/platform/actors/development/actor-definition/dataset-schema" rel="noopener noreferrer"&gt;dataset schema&lt;/a&gt;, the platform-validated contract for every record it outputs. &lt;code&gt;placeOfPerformance.city&lt;/code&gt; is a string or null. &lt;code&gt;primaryContact.email&lt;/code&gt; is a string or null. And so on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://sam.gov" rel="noopener noreferrer"&gt;SAM.gov&lt;/a&gt;'s notice-detail API had other plans. For some notices it returns nested objects where strings should be: a city as &lt;code&gt;{}&lt;/code&gt;, or as &lt;code&gt;{"code": "0"}&lt;/code&gt; instead of a name. My mapping code passed those objects straight through, and I never questioned it, because nothing I ran ever complained.&lt;/p&gt;

&lt;p&gt;On the Apify platform, pushing a record that violates the dataset schema doesn't just drop that record. It aborts the whole &lt;code&gt;pushData&lt;/code&gt; batch with "Schema validation failed." Runs died mid-flight. The platform's automated health checks started failing daily, and the Actor got auto-flagged as broken.&lt;/p&gt;

&lt;p&gt;Here's the part that stung: &lt;strong&gt;local runs never validate the dataset schema.&lt;/strong&gt; My local harness ran the same code against the same API and passed every time. The only environment where the bug existed was the one with paying users in it.&lt;/p&gt;

&lt;p&gt;The fix, shipped in build 0.1.4 on August 12, is a one-function idea: no value reaches a string-typed output field without being coerced. From &lt;code&gt;src/main.js&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Coerce an API value to a trimmed string or null - never an object/array.
 * The SAM.gov detail API sometimes returns nested objects where a string is
 * expected (e.g. placeOfPerformance.city as {} or {"code":"0"} instead of a
 * name). Objects leaking into string fields fail the platform's dataset
 * schema validation, which aborts the whole pushData batch ("Schema
 * validation failed") - the bug that got this actor auto-flagged as broken
 * (local runs never validate the dataset schema, so they passed).
 */&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;asText&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;string&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nf"&gt;clean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;typeof&lt;/span&gt; &lt;span class="nx"&gt;value&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;number&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="nb"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;isFinite&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;value&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kc"&gt;null&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;clean()&lt;/code&gt;, referenced above, is a two-line helper: trim the string, return null when nothing is left.)&lt;/p&gt;

&lt;p&gt;And every mapping line now goes through it, with fallbacks for the API's shape-shifting:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="nx"&gt;record&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;placeOfPerformance&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;street&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;streetAddress&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;city&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;state&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;zip&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;country&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nf"&gt;asText&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;pop&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;country&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;Boring code. That's the point. The dataset schema is a promise, and for an agent consumer it's the &lt;em&gt;only&lt;/em&gt; promise. An agent building a pipeline on my output fields has no human in the loop to notice a city that's suddenly an object. Coerce at the boundary, always, and treat upstream APIs as adversarial about types even when they're government-official.&lt;/p&gt;

&lt;p&gt;The lesson, in one line: &lt;strong&gt;your dataset schema is validated by the platform, not by your laptop.&lt;/strong&gt; If your output can be shaped by an upstream API, run schema validation yourself in CI, or ship the coercion layer from day one.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act two: charging 700 for 500
&lt;/h2&gt;

&lt;p&gt;While reproducing the schema bug I found something worse in the billing data. My Actor uses &lt;a href="https://docs.apify.com/platform/actors/publishing/monetize" rel="noopener noreferrer"&gt;pay-per-event (PPE) pricing&lt;/a&gt;: one &lt;code&gt;contract-opportunity&lt;/code&gt; event, at $0.004, per record delivered.&lt;/p&gt;

&lt;p&gt;I ran two reproduction runs on build 0.1.3 with &lt;code&gt;maxResults: 500&lt;/code&gt;. Both delivered exactly 500 records. Both charged &lt;strong&gt;700 events&lt;/strong&gt;. So I did the arithmetic a customer would do: 700 events at $0.004 is $2.80 billed for $2.00 of delivered data, an extra $0.80 (40%) per run, hiding in the interaction between charging, the aborted &lt;code&gt;pushData&lt;/code&gt; batches from act one, and the Actor's CSV fallback path. And per the failure mode above, a run could charge events and &lt;em&gt;then&lt;/em&gt; die, leaving a user paying for a failed run.&lt;/p&gt;

&lt;p&gt;It's worth walking the incentive structure here, because it's what makes this bug a different animal from a crash. On PPE pricing, every event my code posts is me billing a stranger's account. Apify takes its platform cut and I keep the rest. The user's only protection is that my accounting is honest. Mine wasn't. Not by intent, but the user's card can't tell the difference.&lt;/p&gt;

&lt;p&gt;The &lt;code&gt;chargedEventCounts&lt;/code&gt; on my own repro runs made the signature undeniable:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Build&lt;/th&gt;
&lt;th&gt;Delivered items&lt;/th&gt;
&lt;th&gt;Charged events&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;vaIzPUYUmZbzzuM4I&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1.3&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;700&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;1W2kINLpaI2miHz1m&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1.3&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;700&lt;/strong&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;Z7b0J8yEFuT2XCeSq&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1.4&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;td&gt;500&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;cnr4Y0rP0kUIlXTPN&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;0.1.4&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;td&gt;44&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;The fix in 0.1.4 centralizes the invariant in one function that everything routes through. Charge first, then push only what was actually charged. Delivered items can then never exceed charged events, and the pushed count is whatever the platform confirms it charged, regardless of what my own bookkeeping expected:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="cm"&gt;/**
 * Push dataset items, charging one event per item when pay-per-event is active.
 * Never pushes more items than were successfully charged.
 */&lt;/span&gt;
&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;pushCharged&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pushed&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="na"&gt;limitReached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nf"&gt;isPayPerEvent&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pushed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limitReached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;chargedCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;eventChargeLimitReached&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;charge&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;eventName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;count&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
    &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;chargedCount&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;Actor&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;pushData&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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;chargedCount&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;pushed&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;chargedCount&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;limitReached&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;eventChargeLimitReached&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;isPayPerEvent()&lt;/code&gt; is a one-liner around the SDK's charging manager; it returns false whenever the run isn't on PPE pricing, so the same code works on free and rental runs.)&lt;/p&gt;

&lt;p&gt;A footnote I owe you, because this article is about billing honesty. This 0.1.4 version later turned out to have its own edge case. At the run's charge ceiling the SDK grants a partial charge, and &lt;code&gt;pushData&lt;/code&gt; can then silently drop the batch, billing records that never land: the same harm, arriving by a different door. Build 0.1.8 replaced the pair with the SDK's atomic &lt;code&gt;Actor.pushData(items, eventName)&lt;/code&gt;, which charges exactly what it delivers. If you copy a pattern out of this article, copy that one.&lt;/p&gt;

&lt;p&gt;Then came the uncomfortable part: making affected users whole. I learned that the developer API cannot enumerate other users' runs. &lt;code&gt;GET /v2/acts/{actorId}/runs&lt;/code&gt; returns only runs started by my own account. 7 runs, all mine, while the public stats showed 41. The affected run IDs exist only on Apify's side.&lt;/p&gt;

&lt;p&gt;So I wrote support a &lt;em&gt;charge signature&lt;/em&gt; instead of a run list: any run of my Actor in the window, by any user but me, that either failed with billed PPE events, or succeeded with more billed &lt;code&gt;contract-opportunity&lt;/code&gt; events than clean dataset items. I asked for the refunds to be debited from my payout. I sized the exposure at $1–$7, with an absolute ceiling around $45. Trivial money. But a pay-per-result model &lt;em&gt;is&lt;/em&gt; the trust, and being prompt and boring about making people whole is part of the product, not a gesture on top of it.&lt;/p&gt;

&lt;p&gt;Support answered the next day, and the answer is the reason I asked instead of guessed. Most of those failed runs were Apify's own test system, which runs every Store Actor daily on its default input. The failures I had read as burned customers were largely the platform telling me my Actor was broken, in the one channel I wasn't reading. The real user exposure was a handful of runs from a single free account: &lt;strong&gt;$0.17&lt;/strong&gt;, which support credited directly. Two lessons, and I'd rank them in this order. My blast radius was small because I caught this while I had almost no users, not because I was careful. And the numbers a developer can see (22 failed runs, a 19% failure rate) do not distinguish a robot's daily probe from a paying customer's ruined afternoon. I sized my exposure at up to $45 and was off by two orders of magnitude, in the lucky direction.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;on PPE pricing, your charge accounting is part of your public interface.&lt;/strong&gt; Test it with the same rigor as your output. &lt;code&gt;chargedEventCounts&lt;/code&gt; on your own runs is the ground truth; diff it against your dataset item count in CI if you can.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act three: the configuration that lives only in Console
&lt;/h2&gt;

&lt;p&gt;Two smaller incidents, same root cause: some Actor properties don't live in your repo.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The swapped billing events.&lt;/strong&gt; Earlier in the summer, my NPI and FMCSA Actors (National Provider Identifier registry, and the Federal Motor Carrier Safety Administration's carrier census) had their PPE events swapped in the Console monetization config (NPI charging FMCSA's event and vice versa). The code was correct. The config, which lives only in Console and can't be pushed from the repo, wasn't. The fix was manual, followed by a paid smoke-test run of all the Actors, watching the logs for 'unknown event' warnings. Event names in &lt;a href="https://docs.apify.com/sdk/js/reference/class/Actor#charge" rel="noopener noreferrer"&gt;&lt;code&gt;Actor.charge()&lt;/code&gt;&lt;/a&gt; must match the Console config character for character, and nothing in your repo will tell you they don't.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0leac3c6ypuwllusgzs.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fl0leac3c6ypuwllusgzs.png" alt="Apify Console: an Actor's pay-per-event monetization configuration, with event names and prices" width="798" height="156"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The title that wouldn't ship.&lt;/strong&gt; In an SEO audit I'd rewritten all my Actor titles around what buyers actually search ("lookup", "verification", "no API key"). I updated every &lt;code&gt;.actor/actor.json&lt;/code&gt;, pushed, verified the builds succeeded. The live store titles didn't change. For a published Actor, &lt;code&gt;apify push&lt;/code&gt; updates code and versions but does not propagate &lt;code&gt;title&lt;/code&gt; or &lt;code&gt;description&lt;/code&gt; to the store listing; those are Publication-tab metadata, edited in Console. I verified this the hard way, via the public store API after pushing: &lt;code&gt;modifiedAt&lt;/code&gt; updated, title unchanged.&lt;/p&gt;

&lt;p&gt;And when the titles finally went in through Console, they had to shrink. My approved SEO title for the SAM.gov Actor was 77 characters; what's live is 57. The Publication title field caps at 63 characters, and every title in my portfolio now sits at or under that line. The 63-character reality annoyed me and then improved me: agents discovering Actors as tools see the title and description first, and a title that states the capability in 10 words beats one that lists every keyword.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh00ck6qeavs9fy7qa5s6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fh00ck6qeavs9fy7qa5s6.png" alt="Apify Console: an Actor's Publication tab display information, with the title field and SEO fields" width="780" height="540"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;know which parts of your Actor are code and which are Console state.&lt;/strong&gt; Monetization events, published titles and descriptions, and SEO fields won't version, diff, or deploy with your repo. Keep a checklist for them, because your repo's green build says nothing about them.&lt;/p&gt;

&lt;h2&gt;
  
  
  Act four: the proxy that cuts the stream at 60 seconds
&lt;/h2&gt;

&lt;p&gt;My Florida license Actor streams the official license extracts of Florida's &lt;a href="https://www2.myfloridalicense.com/" rel="noopener noreferrer"&gt;Department of Business and Professional Regulation (DBPR)&lt;/a&gt;, filters rows, and charges per record returned. The construction file alone runs 46.3 MB. These are public-records files the state publishes for this very purpose, and the Actor fetches each one once per run at normal download rates; the proxy business below is about reachability, not volume. In August the Actor kept passing its daily platform health check while 9 of its 48 runs failed. Call it one in five. Whose runs those were, I can't tell you. The developer API shows me my own runs and aggregate counts, never whose run died. It's the same blind spot that had me overestimating the SAM.gov exposure by two orders of magnitude in act two.&lt;/p&gt;

&lt;p&gt;I reproduced it with a whale-shaped input: Miami-Dade county, certified general contractors (license type CGC), &lt;code&gt;maxResults: 1000&lt;/code&gt;, which needs a multi-megabyte scan of the file. DBPR's content delivery network (CDN) blocks datacenter IPs, so the Actor escalates to Apify Proxy. And in every one of my repro runs, &lt;strong&gt;the proxied connection died at roughly 60 seconds mid-stream&lt;/strong&gt;. My code treated the truncated stream as end-of-file. The CSV parser (&lt;a href="https://csv.js.org/parse/" rel="noopener noreferrer"&gt;csv-parse&lt;/a&gt;) hit the truncation mid-quoted-field and crashed the run: &lt;code&gt;Quote Not Closed ... line 15263&lt;/code&gt; (repro run &lt;code&gt;kI1VkPKBOJcYs3JqB&lt;/code&gt;, failed at 1m05s). Worse: 400 of the 1,000 records had already been pushed &lt;em&gt;and charged&lt;/em&gt;. A paying user, paying for a failed run. The worst outcome available on this pricing model.&lt;/p&gt;

&lt;p&gt;Why did the health checks pass? They fetch 25 records, match early, and close the stream well before the 60-second cut. A small-input health check is the one kind of run guaranteed never to see a large-input failure. And there was a nastier latent case: if the cut landed on a row boundary, the run would &lt;em&gt;succeed&lt;/em&gt; with silently truncated data. A human might notice a suspiciously short list. An agent would report it as complete.&lt;/p&gt;

&lt;p&gt;The fix (build 0.1.5, pushed the same night) was to make the download resumable. The DBPR CDN honors HTTP Range requests (I verified the 206 responses and &lt;code&gt;Content-Range&lt;/code&gt; headers), so on any mid-stream cut the Actor resumes from the exact byte offset on a fresh connection, feeding one continuous stream to the parser:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;requestFromOffset&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;proxyUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Promise&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="c1"&gt;// Uncompressed body so byte offsets line up with what we already consumed.&lt;/span&gt;
        &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;headers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;Accept&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;text/csv,*/*&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;Accept-Encoding&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;identity&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;};&lt;/span&gt;
        &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="o"&gt;&amp;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;headers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;Range&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`bytes=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offset&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="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;gotScraping&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
            &lt;span class="nx"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="nx"&gt;proxyUrl&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;timeout&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;response&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;RESPONSE_TIMEOUT_MS&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
            &lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
            &lt;span class="na"&gt;https&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="na"&gt;rejectUnauthorized&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
        &lt;span class="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;on&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;response&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;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;206&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sr"&gt;/^bytes &lt;/span&gt;&lt;span class="se"&gt;(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;-&lt;/span&gt;&lt;span class="se"&gt;\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;\/(\d&lt;/span&gt;&lt;span class="sr"&gt;+&lt;/span&gt;&lt;span class="se"&gt;)&lt;/span&gt;&lt;span class="sr"&gt;$/&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;exec&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
                &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;])&lt;/span&gt; &lt;span class="o"&gt;!==&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                    &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
                    &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`Server returned an unexpected Content-Range "&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-range&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]}&lt;/span&gt;&lt;span class="s2"&gt;" for offset &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;offset&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="p"&gt;}&lt;/span&gt;
                &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;totalBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;match&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="na"&gt;skipBytes&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="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt; &lt;span class="o"&gt;===&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
                &lt;span class="c1"&gt;// Server ignored the Range header and replayed the file:&lt;/span&gt;
                &lt;span class="c1"&gt;// skip the bytes we already have instead of double-counting them.&lt;/span&gt;
                &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;totalBytes&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;content-length&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
                &lt;span class="nf"&gt;resolve&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;totalBytes&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="na"&gt;skipBytes&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;offset&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
                &lt;span class="k"&gt;return&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
            &lt;span class="p"&gt;}&lt;/span&gt;
            &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;destroy&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
            &lt;span class="nf"&gt;reject&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`HTTP &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;statusCode&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; while downloading &lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;url&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="p"&gt;});&lt;/span&gt;
        &lt;span class="nx"&gt;stream&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;once&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;error&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;reject&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;(&lt;code&gt;gotScraping&lt;/code&gt; is the &lt;a href="https://github.com/apify/got-scraping" rel="noopener noreferrer"&gt;got-scraping&lt;/a&gt; HTTP client; &lt;code&gt;RESPONSE_TIMEOUT_MS&lt;/code&gt; is the Actor's response-header timeout constant.)&lt;/p&gt;

&lt;p&gt;Details that mattered in practice:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;Accept-Encoding: identity&lt;/code&gt; matters. With compression on, your byte offsets and the server's don't line up, and Range resumption corrupts silently.&lt;/li&gt;
&lt;li&gt;Check that the 206's &lt;code&gt;Content-Range&lt;/code&gt; starts at &lt;em&gt;your&lt;/em&gt; offset. Some servers answer 206 with the wrong window.&lt;/li&gt;
&lt;li&gt;Handle the server ignoring Range entirely (a 200 replay) by skipping the bytes you already consumed.&lt;/li&gt;
&lt;li&gt;The stream is only ended once &lt;strong&gt;all&lt;/strong&gt; bytes have arrived, which kills both the crash and the silent-truncation case with one invariant.&lt;/li&gt;
&lt;li&gt;An idle watchdog destroys a stalled socket after 60 seconds of no data. Node's &lt;code&gt;pipe()&lt;/code&gt; does not forward source errors; without the watchdog, a dead connection hangs until the run timeout.&lt;/li&gt;
&lt;li&gt;Progress (&lt;code&gt;rowsProcessed&lt;/code&gt;, &lt;code&gt;pushed&lt;/code&gt;) checkpoints to the run's key-value store at every flush. After a platform migration the Actor fast-forwards and never double-pushes or double-charges. I verified this locally with a seeded checkpoint: it resumed at 500 pushed and delivered exactly the remaining 500.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Verification on build 0.1.5, same night:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Run&lt;/th&gt;
&lt;th&gt;Input shape&lt;/th&gt;
&lt;th&gt;Result&lt;/th&gt;
&lt;th&gt;Time&lt;/th&gt;
&lt;th&gt;Platform cost&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;du5uozEtuf72Tycjx&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Miami-Dade + CGC, 1,000 records (the shape that failed)&lt;/td&gt;
&lt;td&gt;1,000 records, 1 cut resumed&lt;/td&gt;
&lt;td&gt;1m 37s&lt;/td&gt;
&lt;td&gt;$0.011&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;nq26dTSyyVsGEBRPh&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Full 46.3 MB scan to end-of-file (worst case)&lt;/td&gt;
&lt;td&gt;241 records, matches the local count, 4 cuts resumed&lt;/td&gt;
&lt;td&gt;4m 16s&lt;/td&gt;
&lt;td&gt;$0.018&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;6bgjK0P7MBhzIrNjO&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Health-check input, 25 records&lt;/td&gt;
&lt;td&gt;25 records&lt;/td&gt;
&lt;td&gt;6s&lt;/td&gt;
&lt;td&gt;$0.001&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Four cuts on a single full-file scan. For any serious input this was the everyday case, and every small test was blind to it.&lt;/p&gt;

&lt;p&gt;The lesson: &lt;strong&gt;small health checks are structurally blind to large-input failures. My 6-second check could never see a cut that happens at 60.&lt;/strong&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  The input schema, or: leaving the agent nothing to guess
&lt;/h2&gt;

&lt;p&gt;The incidents above are about output, billing, and transport. The remaining surface is input, and here the work is preventative. My rule after this month is blunt: an agent should be able to construct a correct call from the schema alone, with nothing to guess.&lt;/p&gt;

&lt;p&gt;Concretely, from the Florida Actor's input schema:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Closed inputs are enums with labels.&lt;/strong&gt; &lt;code&gt;profession&lt;/code&gt; is an &lt;code&gt;enum&lt;/code&gt; of 8 licensing boards with &lt;code&gt;enumTitles&lt;/code&gt; like "Construction contractors (CILB: general, building, residential, roofing, plumbing, HVAC, pool...)". An agent cannot invent a board I don't support.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Formats are machine-checkable.&lt;/strong&gt; Dates carry &lt;code&gt;"pattern": "^\\d{4}-\\d{2}-\\d{2}$"&lt;/code&gt;. A malformed date fails validation with a message, instead of silently matching nothing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Costs are stated where the number is set.&lt;/strong&gt; The &lt;code&gt;maxResults&lt;/code&gt; description says "You are only charged for records actually returned." That sentence is doing billing-anxiety work for humans and budget arithmetic for agents.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Semantics that could surprise are spelled out.&lt;/strong&gt; The monitoring mode's &lt;code&gt;licenseNumbers&lt;/code&gt; watch list deliberately overrides the status filters, because a license that gets suspended must not fall out of its own watch list. The description spells that out, and says why. An agent that reads it configures compliance alerts correctly on the first try.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  What I'd do differently
&lt;/h2&gt;

&lt;p&gt;If I were starting the portfolio again:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Validate output against the dataset schema in CI.&lt;/strong&gt; The platform validates; my laptop didn't. That asymmetry cost me 3 weeks of silent failures. It's the single highest-leverage fix on this list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat charge counts as testable output.&lt;/strong&gt; Assert &lt;code&gt;chargedEventCounts&lt;/code&gt; equals delivered items on a real platform run before every release of a PPE Actor.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Test at the scale users buy, not the scale that's convenient.&lt;/strong&gt; My health checks were 25-record runs; my customer's were ~1,000. Everything interesting happened past the 60-second mark that small runs never reach.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep a Console-state checklist.&lt;/strong&gt; Event names, published title, description, SEO fields, all reviewed by hand, because no diff will catch them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Assume every long-lived connection will be cut&lt;/strong&gt;, and design downloads to resume rather than restart. Range plus &lt;code&gt;identity&lt;/code&gt; encoding plus an idle watchdog is a reusable pattern; I've since carried it into my Texas and California license Actors.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The framing I keep coming back to: humans forgive an Actor its quirks because they can see them. Agents can't see anything you didn't put in the schema, the pricing events, or the error message. Making an Actor "AI-friendly" turned out to mean making it &lt;em&gt;precise&lt;/em&gt;. And every user, human or not, got a better Actor out of it.&lt;/p&gt;

&lt;p&gt;If you're monetizing Actors, pull your own &lt;code&gt;chargedEventCounts&lt;/code&gt; tonight. I thought mine were fine too.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>javascript</category>
      <category>scraping</category>
      <category>automation</category>
    </item>
    <item>
      <title>Trial site selection &amp; investigator mapping with ClinicalTrials.gov's free API (CRO edition)</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Mon, 17 Aug 2026 13:46:21 +0000</pubDate>
      <link>https://dev.to/cblu2005/trial-site-selection-investigator-mapping-with-clinicaltrialsgovs-free-api-cro-edition-3m93</link>
      <guid>https://dev.to/cblu2005/trial-site-selection-investigator-mapping-with-clinicaltrialsgovs-free-api-cro-edition-3m93</guid>
      <description>&lt;p&gt;Every interventional study on ClinicalTrials.gov lists its sites — facility, city, recruitment status, and, very often, the &lt;strong&gt;named principal investigator&lt;/strong&gt; at each one. Which means the world's most complete map of &lt;em&gt;who runs clinical trials, where, on what&lt;/em&gt; is a free public API. If you work in or sell to clinical research, this is your market map:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;CRO / sponsor site selection&lt;/strong&gt; — which facilities in Florida are actively enrolling MASH patients right now? Those sites have the patients, the equipment, and a competing protocol.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Investigator / KOL mapping&lt;/strong&gt; — the named PIs on recruiting oncology trials are the exact people a sponsor, CRO, or medical-affairs team needs on a list.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Competitive enrollment intel&lt;/strong&gt; — before you commit a site, count the trials already recruiting the same population in the same metro. Enrollment risk is quantifiable.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;BD prospecting&lt;/strong&gt; — labs, imaging vendors, ePRO/eCOA platforms, patient-recruitment firms: active sites are your buyers, sorted by indication.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the DIY route against the real API, the three places it gets annoying, and the shortcut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY: API v2 is genuinely good
&lt;/h2&gt;

&lt;p&gt;No key, no account. Recruiting MASH studies with at least one Florida site:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://clinicaltrials.gov/api/v2/studies?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
query.cond=NASH&amp;amp;query.locn=Florida&amp;amp;filter.overallStatus=RECRUITING&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
pageSize=100&amp;amp;fields=protocolSection.identificationModule,&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
protocolSection.contactsLocationsModule,protocolSection.designModule"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Works today, returns JSON, pages cleanly with &lt;code&gt;pageToken&lt;/code&gt;. The &lt;code&gt;contactsLocationsModule&lt;/code&gt; is where the value lives — per-site &lt;code&gt;facility&lt;/code&gt;, &lt;code&gt;city&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, and a &lt;code&gt;contacts&lt;/code&gt; array where entries with &lt;code&gt;"role": "PRINCIPAL_INVESTIGATOR"&lt;/code&gt; are your named investigators.&lt;/p&gt;

&lt;h2&gt;
  
  
  The three annoyances
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Location filters return studies, not sites.&lt;/strong&gt; &lt;code&gt;query.locn=Florida&lt;/code&gt; gives you every study that has &lt;em&gt;a&lt;/em&gt; Florida site — with the full worldwide &lt;code&gt;locations&lt;/code&gt; array attached (large multi-center trials list hundreds of sites). Your "Florida site list" requires walking every study's location array and keeping only the Florida rows yourself. This is the big one: the question is site-shaped, the API is study-shaped.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;The JSON is deeply nested and inconsistently populated.&lt;/strong&gt; Everything hides under &lt;code&gt;protocolSection.&amp;lt;something&amp;gt;Module&lt;/code&gt;, and a "simple" flat row means stitching identification, status, design, sponsor, and contacts modules together. PI naming also varies by sponsor type: academic centers usually name investigators (Mayo Clinic trials list PIs with credentials), while some industry sponsors anonymize sites to "89bio Clinical Study Site" with a call-center contact — your pipeline needs to handle both without pretending one is the other.&lt;/p&gt;&lt;/li&gt;
&lt;li&gt;&lt;p&gt;&lt;strong&gt;Field selection is its own language.&lt;/strong&gt; The &lt;code&gt;fields&lt;/code&gt; parameter wants exact dotted module paths; get one wrong and it errors (better than silence, but still a docs-diving session). Filters similarly split across &lt;code&gt;query.*&lt;/code&gt; (search-y) and &lt;code&gt;filter.*&lt;/code&gt; (exact) families with different semantics — &lt;code&gt;query.cond&lt;/code&gt;, &lt;code&gt;query.spons&lt;/code&gt;, &lt;code&gt;filter.overallStatus&lt;/code&gt;, aggregate filters, and so on.&lt;/p&gt;&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A workable Python flattener for site-level rows:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;florida_sites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Florida&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;{&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query.cond&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;condition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;query.locn&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;filter.overallStatus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;RECRUITING&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageSize&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://clinicaltrials.gov/api/v2/studies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;url&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="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;study&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;data&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;studies&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[]):&lt;/span&gt;
            &lt;span class="n"&gt;ps&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;study&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;protocolSection&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;nct&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;identificationModule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nctId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
            &lt;span class="n"&gt;title&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;ps&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;identificationModule&lt;/span&gt;&lt;span class="sh"&gt;"&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;briefTitle&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;loc&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;ps&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;contactsLocationsModule&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;locations&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;loc&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;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="n"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
                    &lt;span class="k"&gt;continue&lt;/span&gt;  &lt;span class="c1"&gt;# the study-shaped-vs-site-shaped problem
&lt;/span&gt;                &lt;span class="n"&gt;pis&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="n"&gt;c&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;c&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;loc&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;contacts&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;c&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;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;PRINCIPAL_INVESTIGATOR&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
                &lt;span class="k"&gt;yield&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;nctId&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;nct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;title&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;facility&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc&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;facility&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc&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;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;siteStatus&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;loc&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;status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;investigators&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pis&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;token&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;nextPageToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)):&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pageToken&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;token&lt;/span&gt;

&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nf"&gt;florida_sites&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;NASH&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Fine for one query. The maintenance shows up when you want this across indications, sponsor classes, and phases, refreshed weekly, in a spreadsheet your feasibility team can actually open.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcut: one input, flat study records with sites and PIs attached
&lt;/h2&gt;

&lt;p&gt;I maintain an Apify actor that wraps the v2 API — module-stitching, paging, and contact extraction included:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/clinical-trials-scraper" rel="noopener noreferrer"&gt;ClinicalTrials.gov Scraper&lt;/a&gt;&lt;/strong&gt;&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"condition"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"NASH"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"RECRUITING"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"phases"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"PHASE2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"PHASE3"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"sponsorClasses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"INDUSTRY"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Florida"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="w"&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;Each record is one study, flattened: identification, phase, enrollment, lead sponsor, &lt;code&gt;centralContacts&lt;/code&gt;, &lt;code&gt;overallOfficials&lt;/code&gt;, and a clean &lt;code&gt;locations&lt;/code&gt; array where every site carries its status and a ready-made &lt;code&gt;principalInvestigators&lt;/code&gt; list. Export JSON/CSV/Excel, schedule it weekly on &lt;a href="https://docs.apify.com/platform/schedules" rel="noopener noreferrer"&gt;Apify Schedules&lt;/a&gt; for a standing feasibility feed, and pricing is per study returned — a 500-study indication landscape costs about a dollar and a half.&lt;/p&gt;

&lt;p&gt;Filter by &lt;code&gt;sponsor&lt;/code&gt; (e.g. &lt;code&gt;Pfizer&lt;/code&gt;) to map a competitor's entire active footprint, or by &lt;code&gt;intervention&lt;/code&gt; (e.g. &lt;code&gt;semaglutide&lt;/code&gt;) to watch a mechanism.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;ClinicalTrials.gov's v2 API is free, keyless, and contains the industry's site + investigator map.&lt;/li&gt;
&lt;li&gt;The DIY tax: study-shaped responses for site-shaped questions, deep module-stitching, and per-sponsor contact inconsistency.&lt;/li&gt;
&lt;li&gt;Thirty lines of Python covers one query; the actor covers the recurring, multi-indication version with named PIs already extracted.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Working a specific feasibility question — an indication, a geography, a competitor? Drop a comment and I'll sketch the exact input.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>data</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Federal contracts: see who actually WON — post-award intel from USAspending's free API</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:04:21 +0000</pubDate>
      <link>https://dev.to/cblu2005/federal-contracts-see-who-actually-won-post-award-intel-from-usaspendings-free-api-2on2</link>
      <guid>https://dev.to/cblu2005/federal-contracts-see-who-actually-won-post-award-intel-from-usaspendings-free-api-2on2</guid>
      <description>&lt;p&gt;SAM.gov tells you what the government &lt;em&gt;wants to buy&lt;/em&gt; (I covered &lt;a href="https://dev.to/cblu2005/track-samgov-contract-opportunities-without-an-api-key-or-a-300month-govcon-subscription-2mmk"&gt;tracking it without an API key&lt;/a&gt; earlier). But the pre-award side is only half the picture. The other half — the half your competitors' business developers are quietly living in — is &lt;strong&gt;who won, for how much, from which agency, and when that contract runs out.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;That data is USAspending.gov: every federal contract, grant, loan, and direct payment, published under the DATA Act. It answers questions SAM.gov can't:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Competitive intel&lt;/strong&gt; — who actually wins IT-services contracts in Texas? At what dollar sizes? For which agencies?&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recompete pipeline&lt;/strong&gt; — an award with an end date 9 months out is a solicitation waiting to happen. Incumbents know. Now you do too.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Teaming targets&lt;/strong&gt; — small primes winning work in your NAICS are potential partners (or acquisition targets).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Grant prospecting&lt;/strong&gt; — universities and nonprofits: see exactly which programs fund organizations like yours, and how much.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;And unlike SAM.gov's official API, USAspending's API needs &lt;strong&gt;no key, no account, no registration.&lt;/strong&gt; It's genuinely open. Here's the honest DIY, where it bites, and the shortcut.&lt;/p&gt;

&lt;h2&gt;
  
  
  The DIY: one POST endpoint does most of it
&lt;/h2&gt;

&lt;p&gt;The workhorse is &lt;code&gt;spending_by_award&lt;/code&gt; — POST your filters, get awards back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-s&lt;/span&gt; &lt;span class="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.usaspending.gov/api/v2/search/spending_by_award/"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Content-Type: application/json"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-d&lt;/span&gt; &lt;span class="s1"&gt;'{
    "filters": {
      "award_type_codes": ["A", "B", "C", "D"],
      "time_period": [{"start_date": "2026-05-01", "end_date": "2026-08-12"}],
      "naics_codes": ["541511"],
      "place_of_performance_locations": [{"country": "USA", "state": "TX"}]
    },
    "fields": ["Award ID", "Recipient Name", "Award Amount", "Awarding Agency",
               "Start Date", "End Date", "Description", "generated_internal_id"],
    "sort": "Award Amount", "order": "desc", "limit": 25, "page": 1
  }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That call works today, unauthenticated (custom-programming contracts performed in Texas, biggest first — the top hit as I write this is a $37.7M DoD award). Every result's &lt;code&gt;generated_internal_id&lt;/code&gt; gives you a shareable page: &lt;code&gt;https://www.usaspending.gov/award/&amp;lt;id&amp;gt;&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where it bites
&lt;/h2&gt;

&lt;p&gt;The API is free and stable — and its query grammar was clearly designed for USAspending's own frontend, not for you:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;award_type_codes&lt;/code&gt; is mandatory and cryptic.&lt;/strong&gt; Contracts are &lt;code&gt;A&lt;/code&gt;–&lt;code&gt;D&lt;/code&gt;, contract IDVs are &lt;code&gt;IDV_A&lt;/code&gt;–&lt;code&gt;IDV_E&lt;/code&gt;, grants are &lt;code&gt;02&lt;/code&gt;/&lt;code&gt;03&lt;/code&gt;/&lt;code&gt;04&lt;/code&gt;/&lt;code&gt;05&lt;/code&gt;, direct payments &lt;code&gt;06&lt;/code&gt;/&lt;code&gt;10&lt;/code&gt;, loans &lt;code&gt;07&lt;/code&gt;/&lt;code&gt;08&lt;/code&gt;. Send an empty filter set and you get a 422, not "everything."&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Contracts and grants can't be queried together properly.&lt;/strong&gt; Several filters (&lt;code&gt;naics_codes&lt;/code&gt;, PSC) only apply to contracts; CFDA program numbers only to assistance. Real coverage means separate queries per award group, merged and deduplicated yourself.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;code&gt;fields&lt;/code&gt; are magic display strings.&lt;/strong&gt; You request columns by their UI names — &lt;code&gt;"Recipient Name"&lt;/code&gt;, &lt;code&gt;"Award Amount"&lt;/code&gt; — and misspellings just silently vanish from the response. Contract and grant responses also name overlapping things differently (&lt;code&gt;Award Type&lt;/code&gt; vs &lt;code&gt;Contract Award Type&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The good stuff needs a second call per award.&lt;/strong&gt; Recipient mailing address, business categories, executive compensation — all on the award detail endpoint (&lt;code&gt;/api/v2/awards/&amp;lt;generated_internal_id&amp;gt;/&lt;/code&gt;), one round trip each. A 500-award pull becomes 501 requests with polite pacing.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Occasional nested surprises&lt;/strong&gt; — a field that's a string on one award is an object on the next. Your flattener finds out in production.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;None of this is a dealbreaker; it's a long afternoon plus perpetual maintenance. If you enjoy that afternoon, the &lt;a href="https://api.usaspending.gov/" rel="noopener noreferrer"&gt;official docs&lt;/a&gt; are decent.&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcut: filters in, flat records out
&lt;/h2&gt;

&lt;p&gt;I maintain an Apify actor that wraps all of the above — award-group fan-out, field normalization, optional per-award enrichment, paging, dedupe:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/usaspending-federal-awards-scraper" rel="noopener noreferrer"&gt;USAspending Federal Awards Scraper&lt;/a&gt;&lt;/strong&gt;&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"awardTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"contracts"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"naicsCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"541511"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"placeOfPerformanceStates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TX"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"timePeriodStart"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-05-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minAwardAmount"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;250000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"includeRecipientAddress"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="w"&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;Every record comes out flat and consistently named — &lt;code&gt;recipientName&lt;/code&gt;, &lt;code&gt;recipientUei&lt;/code&gt;, &lt;code&gt;awardAmount&lt;/code&gt;, &lt;code&gt;awardingAgency&lt;/code&gt;, &lt;code&gt;startDate&lt;/code&gt;, &lt;code&gt;endDate&lt;/code&gt;, &lt;code&gt;naics&lt;/code&gt;, &lt;code&gt;usaspendingUrl&lt;/code&gt;, plus the recipient's address and business categories when you flip &lt;code&gt;includeRecipientAddress&lt;/code&gt; — with JSON/CSV/Excel export and pricing per record returned (a 500-award competitive-landscape pull costs about a dollar and a half).&lt;/p&gt;

&lt;p&gt;Grants work the same way — swap &lt;code&gt;awardTypes&lt;/code&gt; to &lt;code&gt;["grants"]&lt;/code&gt; and filter by &lt;code&gt;cfdaNumbers&lt;/code&gt; (e.g. &lt;code&gt;93.778&lt;/code&gt; for Medicaid) or &lt;code&gt;awardingAgency&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The play that pays: pre-award + post-award together
&lt;/h2&gt;

&lt;p&gt;The two datasets are a pincer:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;USAspending (this post):&lt;/strong&gt; find every contract in your NAICS ending in the next 12 months. That's your recompete target list, with the incumbent, the agency, and the dollar value attached.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&lt;a href="https://dev.to/cblu2005/track-samgov-contract-opportunities-without-an-api-key-or-a-300month-govcon-subscription-2mmk"&gt;SAM.gov&lt;/a&gt;:&lt;/strong&gt; watch for the recompete solicitation to drop — with the contracting officer's name and email on it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Schedule both weekly on &lt;a href="https://docs.apify.com/platform/schedules" rel="noopener noreferrer"&gt;Apify Schedules&lt;/a&gt; and you've rebuilt the core of a $300/month GovCon intelligence subscription for a few dollars of data charges.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;USAspending.gov's API is free, keyless, and covers every federal award — the post-award half of GovCon intel.&lt;/li&gt;
&lt;li&gt;DIY is one POST endpoint plus real friction: cryptic mandatory type codes, split contract/grant querying, magic field names, N+1 enrichment.&lt;/li&gt;
&lt;li&gt;The actor flattens all of it behind one input; pair it with SAM.gov monitoring for the full pre-award + post-award picture.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If there's a specific slice you're trying to pull — a NAICS, an agency, a grant program — drop a comment and I'll sketch the exact input.&lt;/p&gt;

</description>
      <category>government</category>
      <category>data</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Verify Florida contractor licenses in bulk — DBPR's 46 MB mystery CSV, decoded</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 18:00:12 +0000</pubDate>
      <link>https://dev.to/cblu2005/verify-florida-contractor-licenses-in-bulk-dbprs-46-mb-mystery-csv-decoded-3ebh</link>
      <guid>https://dev.to/cblu2005/verify-florida-contractor-licenses-in-bulk-dbprs-46-mb-mystery-csv-decoded-3ebh</guid>
      <description>&lt;p&gt;If your business touches Florida construction — insurance, lending, building materials, a marketplace, a permit-pulling SaaS — sooner or later you need to answer, at scale: &lt;strong&gt;is this contractor actually licensed, and is that license current, active, and not about to expire?&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Florida makes this genuinely answerable. Unlike states with no statewide general-contractor license (looking at you, Texas), Florida's DBPR licenses contractors statewide and publishes the entire license roll as public records. The catch: the official ways to read it are either painfully slow or painfully cryptic. This post shows both DIY routes honestly, then the shortcut, and finally how to turn a one-off check into standing compliance alerts.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 1: the search portal (fine for one, hopeless for a list)
&lt;/h2&gt;

&lt;p&gt;DBPR's &lt;a href="https://www.myfloridalicense.com/wl11.asp" rel="noopener noreferrer"&gt;Verify a License&lt;/a&gt; portal works one search at a time: pick a board, type a name or license number, click through to a detail page. For a single sub you're about to hire, perfect. For the 400 subcontractors on your book, that's an afternoon of copy-paste — every week, if you care about status changes. Nobody does this twice.&lt;/p&gt;

&lt;h2&gt;
  
  
  Route 2: the bulk extract files (free, complete, and user-hostile)
&lt;/h2&gt;

&lt;p&gt;The real data lives on DBPR's &lt;a href="https://www2.myfloridalicense.com/instant-public-records/" rel="noopener noreferrer"&gt;instant public records page&lt;/a&gt;: full statewide extract files per profession, regenerated on a daily/weekly cadence. The construction file is one URL away:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="nt"&gt;-O&lt;/span&gt; &lt;span class="s2"&gt;"https://www2.myfloridalicense.com/sto/file_download/extracts/CONSTRUCTIONLICENSE_1.csv"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's every licensed contractor in Florida — 46 MB of it (the cosmetology file is ~74 MB). Now the friction starts:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;No header row.&lt;/strong&gt; The file opens with data. There are 22 columns and DBPR doesn't label them in the file.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything is coded.&lt;/strong&gt; Counties are numbers (&lt;code&gt;23&lt;/code&gt; = Miami-Dade, &lt;code&gt;60&lt;/code&gt; = Palm Beach — the mapping is on DBPR's &lt;a href="https://www2.myfloridalicense.com/about-us/understanding-dbpr-codes/" rel="noopener noreferrer"&gt;"understanding DBPR codes"&lt;/a&gt; page). Statuses are letters: primary &lt;code&gt;C&lt;/code&gt;/&lt;code&gt;P&lt;/code&gt;/&lt;code&gt;S&lt;/code&gt;/&lt;code&gt;N&lt;/code&gt;/&lt;code&gt;D&lt;/code&gt; = Current / Probation / Suspended / Null-and-Void / Delinquent, secondary &lt;code&gt;A&lt;/code&gt;/&lt;code&gt;I&lt;/code&gt; = Active / Inactive. A usable license is &lt;code&gt;C&lt;/code&gt; + &lt;code&gt;A&lt;/code&gt; — most rows aren't.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Two license-number columns.&lt;/strong&gt; Column 13 is a bare sequence (&lt;code&gt;0015061&lt;/code&gt;); the full number you actually want (&lt;code&gt;CBC015061&lt;/code&gt;) is column 21.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rows that aren't licenses.&lt;/strong&gt; Qualified-business entries ride along with no license number and have to be skipped.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The CDN blocks datacenter IPs.&lt;/strong&gt; The download works from your laptop and then 403s from AWS/GitHub Actions — precisely where your cron job lives. (Ask me how I know.)&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;What's &lt;em&gt;missing&lt;/em&gt; matters.&lt;/strong&gt; DBPR excludes null-and-void, delinquent, and involuntarily-inactive licenses from the extracts. A contractor disappearing from the file &lt;em&gt;is itself a signal.&lt;/em&gt;
&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Here's a working decoder for the construction file (tested against the live extract):&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;pandas&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;

&lt;span class="c1"&gt;# 22 columns, no header row - mapping reverse-engineered from DBPR's code tables
&lt;/span&gt;&lt;span class="n"&gt;COLS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;board&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;license_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;licensee_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;dba_name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_4&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addr1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addr2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;addr3&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;city&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;zip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;county_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;license_seq&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;original_license_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;status_effective_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;expiration_date&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_18&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_19&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;license_number&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;notes&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;

&lt;span class="n"&gt;PRIMARY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;C&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;P&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Probation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;S&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Suspended&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
           &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;N&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Null and Void&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;D&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Delinquent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;span class="n"&gt;SECONDARY&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;A&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;I&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Inactive&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="n"&gt;df&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;pd&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;read_csv&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CONSTRUCTIONLICENSE_1.csv&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;COLS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;dtype&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="nb"&gt;str&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                 &lt;span class="n"&gt;keep_default_na&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="bp"&gt;False&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;primary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PRIMARY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;secondary_status&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;SECONDARY&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

&lt;span class="c1"&gt;# Every active certified general contractor in Palm Beach County (code 60):
&lt;/span&gt;&lt;span class="n"&gt;cgc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;license_type&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;CGC&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;county_code&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;60&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
         &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;primary_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Current&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;df&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;secondary_status&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)]&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cgc&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;active Palm Beach CGCs&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;To verify &lt;em&gt;your&lt;/em&gt; list, inner-join it on &lt;code&gt;license_number&lt;/code&gt; and flag anything that isn't Current + Active — plus anything from your list that's &lt;strong&gt;not in the file at all&lt;/strong&gt; (see point 6).&lt;/p&gt;

&lt;p&gt;If you only need this once, the DIY route is genuinely fine. The pain compounds when you need it fresh: re-downloading 46-74 MB files, babysitting the 403s, re-checking the column layout, decoding county/status/type codes for eight different boards (electrical, home inspectors, mold, cosmetology... each its own file).&lt;/p&gt;

&lt;h2&gt;
  
  
  The shortcut: one input, clean records
&lt;/h2&gt;

&lt;p&gt;I maintain an Apify actor that streams the official extracts and hands back decoded, filterable records — county names instead of codes, ISO dates, human-readable statuses and license types, QB rows skipped, proxy business handled:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/florida-license-records-scraper" rel="noopener noreferrer"&gt;Florida License Records Scraper (DBPR)&lt;/a&gt;&lt;/strong&gt;&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profession"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"construction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"licenseTypes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CGC"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"counties"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"Palm Beach"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"status"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"current-active"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"expiresBefore"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-12-31"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="w"&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;Statewide pulls take about a minute, export as JSON/CSV/Excel, and pricing is per record returned — a 1,000-contractor county list costs a couple of dollars. Eight DBPR boards are covered: construction, electrical, home inspectors, mold services, cosmetology, barbers, veterinary, architecture.&lt;/p&gt;

&lt;h2&gt;
  
  
  The part that actually solves compliance: monitoring mode
&lt;/h2&gt;

&lt;p&gt;Bulk verification has a shelf life of exactly one day — DBPR regenerates the files that often. What compliance teams really want isn't a snapshot, it's an alarm: &lt;em&gt;tell me when something changes.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The actor has a &lt;code&gt;monitor&lt;/code&gt; mode for that. Give it a watch list (or a filter set), run it on an &lt;a href="https://docs.apify.com/platform/schedules" rel="noopener noreferrer"&gt;Apify Schedule&lt;/a&gt;, and each run returns &lt;strong&gt;only what changed&lt;/strong&gt; since the last one:&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"mode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"monitor"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"profession"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"construction"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"licenseNumbers"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"CGC058548"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CCC1330911"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"CFC1425030"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"alertOnStatusChange"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"expiringWithinDays"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="w"&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;The first run saves a baseline. Every scheduled run after that emits change records only: &lt;code&gt;status-changed&lt;/code&gt; (Current → Suspended is the one you're paying attention for), &lt;code&gt;renewed&lt;/code&gt;, &lt;code&gt;expiring-soon&lt;/code&gt; (once per license as it enters your 90-day window, with a &lt;code&gt;daysUntilExpiration&lt;/code&gt; field), &lt;code&gt;new-license&lt;/code&gt;, and &lt;code&gt;removed-from-extract&lt;/code&gt; — that disappearing-contractor signal from point 6, delivered instead of silently missed.&lt;/p&gt;

&lt;p&gt;Because pricing is per record returned, &lt;strong&gt;a run where nothing changed costs roughly a cent of compute and zero record charges.&lt;/strong&gt; Watching 500 subcontractors costs approximately nothing until the week one of them gets suspended — which is the week it pays for itself a few hundred times over. Wire the run into a Slack/email &lt;a href="https://docs.apify.com/platform/integrations" rel="noopener noreferrer"&gt;integration&lt;/a&gt; and you're done: set-and-forget license compliance.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Florida publishes its complete contractor license roll as free public extract files — the data access problem is solved.&lt;/li&gt;
&lt;li&gt;The files are honest work to use: headerless 22-column CSVs, coded everything, datacenter-IP blocking, and meaningful &lt;em&gt;absences&lt;/em&gt;.&lt;/li&gt;
&lt;li&gt;Decode it yourself with ~25 lines of pandas for a one-off; use the actor for clean filtered pulls; use monitor mode on a schedule when what you actually need is "alert me when a license changes."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Fighting with a different state's license data? Drop a comment — Texas TDLR and California CSLB are next on my list.&lt;/p&gt;

</description>
      <category>python</category>
      <category>opendata</category>
      <category>tutorial</category>
      <category>api</category>
    </item>
    <item>
      <title>Query every dentist (or physician) in a metro without hitting NPPES's 1,200-record wall</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 14:07:29 +0000</pubDate>
      <link>https://dev.to/cblu2005/query-every-dentist-or-physician-in-a-metro-without-hitting-nppess-1200-record-wall-31e3</link>
      <guid>https://dev.to/cblu2005/query-every-dentist-or-physician-in-a-metro-without-hitting-nppess-1200-record-wall-31e3</guid>
      <description>&lt;p&gt;Every US healthcare provider — every physician, dentist, nurse practitioner, physical therapist, pharmacy, and clinic — has a National Provider Identifier, and the whole registry is public. CMS runs it as &lt;strong&gt;NPPES&lt;/strong&gt;, and it has a free, keyless API. If you sell to providers (dental supplies, medical devices, EHR/billing software, staffing), this is the authoritative list of your entire market, with practice addresses and phone numbers, for free.&lt;/p&gt;

&lt;p&gt;There's one catch, and it's the reason so many "NPI scrapers" quietly return garbage: &lt;strong&gt;the NPPES API hard-caps every search at 1,200 records — and past that cap it doesn't error, it silently repeats the same page.&lt;/strong&gt; If a metro has 4,000 dentists and you page naively, you'll get 1,200 real ones followed by duplicates of the last page, feel like you got "a lot of data," and never realize two-thirds of your market is missing.&lt;/p&gt;

&lt;p&gt;This post explains the wall, shows the manual way through it, and gives you a copy-paste Python fix.&lt;/p&gt;

&lt;h2&gt;
  
  
  The API, and the wall
&lt;/h2&gt;

&lt;p&gt;The base call is simple. Dentists in Florida:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://npiregistry.cms.hhs.gov/api/?version=2.1&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
taxonomy_description=Dentist&amp;amp;state=FL&amp;amp;limit=200"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;limit&lt;/code&gt; maxes out at &lt;strong&gt;200&lt;/strong&gt; per call. To go deeper you add &lt;code&gt;skip&lt;/code&gt;:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://npiregistry.cms.hhs.gov/api/?version=2.1&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
taxonomy_description=Dentist&amp;amp;state=FL&amp;amp;limit=200&amp;amp;skip=1000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And here's the wall: &lt;strong&gt;&lt;code&gt;skip&lt;/code&gt; maxes out at 1,000.&lt;/strong&gt; 1,000 skipped + 200 returned = &lt;strong&gt;1,200 records, full stop.&lt;/strong&gt; Ask for &lt;code&gt;skip=1200&lt;/code&gt; and the API doesn't say "out of range" — it hands you the &lt;code&gt;skip=1000&lt;/code&gt; page again. Naive pagers treat that as more data and append duplicates forever. (You'll also notice very broad searches — a whole state with no other filter — get rejected outright; NPPES requires at least one criterion beyond &lt;code&gt;state&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;So the real problem isn't fetching data. It's fetching &lt;em&gt;complete&lt;/em&gt; data for any search that legitimately has more than 1,200 matches — which is most useful ones (dentists in Miami, PTs in Houston, family medicine in all of California).&lt;/p&gt;

&lt;h2&gt;
  
  
  The manual fix: subdivide until every slice fits under 1,200
&lt;/h2&gt;

&lt;p&gt;The trick is to split one too-big search into several small-enough searches, then merge and de-duplicate. The cleanest axis to split on is &lt;strong&gt;ZIP code prefix&lt;/strong&gt;, because NPPES supports trailing wildcards on &lt;code&gt;postal_code&lt;/code&gt;. "All Miami dentists" (too big) becomes "dentists in 331xx," then if &lt;em&gt;that's&lt;/em&gt; still over 1,200, "3310x, 3311x, 3312x…," and so on. You recurse only into the slices that are actually full, and you de-duplicate the final set by NPI (a provider with a practice and a mailing address in different ZIPs can match twice).&lt;/p&gt;

&lt;p&gt;Here's the whole thing in Python — no dependencies beyond &lt;code&gt;requests&lt;/code&gt;:&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="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;API&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://npiregistry.cms.hhs.gov/api/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1200&lt;/span&gt;          &lt;span class="c1"&gt;# NPPES hard cap per search
&lt;/span&gt;&lt;span class="n"&gt;PAGE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;200&lt;/span&gt;          &lt;span class="c1"&gt;# max limit per call
&lt;/span&gt;
&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;_page_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Fetch up to the 1,200 cap for one exact search.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;API&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="p"&gt;{&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;version&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2.1&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
                                       &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;limit&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;PAGE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;skip&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;skip&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;raise_for_status&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&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;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&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;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="n"&gt;PAGE&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="k"&gt;break&lt;/span&gt;
        &lt;span class="n"&gt;skip&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;PAGE&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zip_prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="sh"&gt;"""&lt;/span&gt;&lt;span class="s"&gt;Recursively subdivide by ZIP prefix so no slice exceeds the cap.&lt;/span&gt;&lt;span class="sh"&gt;"""&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&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;zip_prefix&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;postal_code&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;zip_prefix&lt;/span&gt; &lt;span class="o"&gt;+&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="n"&gt;hits&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;_page_all&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="c1"&gt;# If we came back pinned at the cap, this slice is probably truncated —
&lt;/span&gt;    &lt;span class="c1"&gt;# split it into 10 narrower ZIP prefixes and recurse.
&lt;/span&gt;    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hits&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;CAP&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;zip_prefix&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;deeper&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
        &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;0123456789&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;deeper&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;extend&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;base&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;zip_prefix&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;deeper&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;hits&lt;/span&gt;

&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
    &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;set&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;rec&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;records&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;npi&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;rec&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;number&lt;/span&gt;&lt;span class="sh"&gt;"&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;npi&lt;/span&gt; &lt;span class="ow"&gt;and&lt;/span&gt; &lt;span class="n"&gt;npi&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;seen&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;add&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;npi&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
            &lt;span class="n"&gt;out&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;out&lt;/span&gt;

&lt;span class="c1"&gt;# Every dentist in greater Miami, complete:
&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;search&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;taxonomy_description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Dentist&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;state&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;FL&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;zip_prefix&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;331&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="n"&gt;providers&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;dedupe&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;raw&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="nf"&gt;len&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;providers&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt; unique dentists (naive paging would have stopped at 1,200)&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;Run that against a dense metro and you'll see it sail past 1,200 — a Miami-area dentist search returns close to 2,000 unique providers, not the 1,200 a single search reports. That gap is exactly the providers a naive scraper never sees.&lt;/p&gt;

&lt;p&gt;A few things to add before you rely on it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Be polite.&lt;/strong&gt; Add a small delay between calls and a retry/backoff — NPPES will throttle a tight loop. The recursion above can fire dozens of calls for a dense metro.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull the fields you actually need&lt;/strong&gt; out of each record: &lt;code&gt;number&lt;/code&gt; (the NPI), the taxonomy marked &lt;code&gt;primary&lt;/code&gt; for specialty and license, and the address where &lt;code&gt;address_purpose == "LOCATION"&lt;/code&gt; for the practice phone. NPPES nests these in arrays, so a naive &lt;code&gt;record["phone"]&lt;/code&gt; won't exist.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Emails aren't in here.&lt;/strong&gt; NPPES publishes practice phone, fax, and address, plus the authorized official's name and phone for organizations — but not provider email. Any tool selling you "NPI emails" is enriching from somewhere else; treat those with the skepticism they deserve.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The maintained shortcut
&lt;/h2&gt;

&lt;p&gt;I wrapped this exact approach — the cap detection, the ZIP fan-out, the de-duplication, the field extraction, the backoff — into an Apify actor so you don't have to own the recursion:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/npi-healthcare-providers-scraper" rel="noopener noreferrer"&gt;NPI Registry Scraper (NPPES Healthcare Providers)&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The complete-Miami-dentists query becomes:&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"taxonomyDescription"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Dentist"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"state"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FL"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"postalCode"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"331*"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="w"&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;It returns clean, flat records — &lt;code&gt;npi&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;credential&lt;/code&gt;, &lt;code&gt;specialty&lt;/code&gt;, &lt;code&gt;licenseNumber&lt;/code&gt;, &lt;code&gt;licenseState&lt;/code&gt;, &lt;code&gt;practiceAddress&lt;/code&gt; (with phone and fax), &lt;code&gt;authorizedOfficial&lt;/code&gt; for organizations — and hands you JSON/CSV/Excel, scheduling, and webhooks. You're charged per record returned, so a ~2,000-provider metro list costs about four dollars. The point of paying isn't access to the data (it's free and keyless); it's that the 1,200-cap fan-out, the paging, and the field-unnesting are already correct and maintained.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which route should you take?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One search that's clearly under 1,200&lt;/strong&gt; (a single small city and specialty) → just page it yourself with the first snippet. No fan-out needed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any dense metro or statewide specialty, on a schedule, or feeding a CRM or AI agent&lt;/strong&gt; → use the fan-out. The actor is the least-effort version of it (and it's callable as an MCP tool via Apify, so "how many pediatric dentists are in the Dallas metro?" becomes a one-line agent query).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A one-off count for a single ZIP&lt;/strong&gt; → honestly, the &lt;a href="https://npiregistry.cms.hhs.gov/" rel="noopener noreferrer"&gt;NPPES website&lt;/a&gt; is fine; don't automate a thing you'll do once.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The registry is public infrastructure paid for with tax dollars. The only thing standing between you and a complete provider list is knowing that 1,200 isn't the end — it's where the naive scrapers give up.&lt;/p&gt;

&lt;p&gt;Hit a specialty or metro where the fan-out behaves oddly? Drop a comment — the taxonomy descriptions have some sharp edges (there are three different flavors of "nurse practitioner" alone) and I'm happy to compare notes.&lt;/p&gt;

</description>
      <category>healthcare</category>
      <category>api</category>
      <category>data</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Build a fresh trucking-insurance lead feed: every new US carrier with phone + email, updated weekly</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 14:01:58 +0000</pubDate>
      <link>https://dev.to/cblu2005/build-a-fresh-trucking-insurance-lead-feed-every-new-us-carrier-with-phone-email-updated-weekly-27je</link>
      <guid>https://dev.to/cblu2005/build-a-fresh-trucking-insurance-lead-feed-every-new-us-carrier-with-phone-email-updated-weekly-27je</guid>
      <description>&lt;p&gt;When a trucking company registers with the FMCSA and gets its authority, the clock starts on a bunch of things it now legally has to buy: primary liability and cargo insurance, a BOC-3 process agent, an ELD, often factoring to survive the 30–60 day payment cycle. Whoever reaches that carrier &lt;em&gt;first&lt;/em&gt; — in the days after it registers, while it's still shopping — wins the account.&lt;/p&gt;

&lt;p&gt;The good news for anyone selling into that market: the list of who just registered is a public federal record, published on an official US DOT open-data portal, with &lt;strong&gt;phone and email on file for essentially every carrier registered in the last couple of years.&lt;/strong&gt; You do not need to scrape SAFER, solve captchas, or buy a $300/month lead list. You need one HTTPS request and a filter.&lt;/p&gt;

&lt;p&gt;This post shows how to pull it yourself, where it gets fiddly, and how to turn it into a scheduled weekly feed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where the data lives
&lt;/h2&gt;

&lt;p&gt;Everything SAFER shows you about a carrier — legal name, DBA, address, phone, email, company officers, fleet size, cargo, hazmat flag, safety rating — is rendered from FMCSA's &lt;strong&gt;Company Census File&lt;/strong&gt;. FMCSA publishes that census on &lt;code&gt;data.transportation.gov&lt;/code&gt;, the US DOT's open-data portal, which runs on Socrata. That means the same SODA API you'd use for any city open-data set: plain HTTPS in, JSON out, SQL-ish query params, no API key for moderate use.&lt;/p&gt;

&lt;p&gt;First, find the current dataset. Portal dataset IDs occasionally change when an agency re-publishes, so don't hard-code one you found in a blog post — ask the catalog:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.us.socrata.com/api/catalog/v1?domains=data.transportation.gov&amp;amp;q=motor%20carrier%20census&amp;amp;only=datasets"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That returns the dataset's &lt;code&gt;id&lt;/code&gt; (a four-four Socrata token like &lt;code&gt;az4n-8mr2&lt;/code&gt;) and its resource URL. From there the census is queryable at:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://data.transportation.gov/resource/&amp;lt;dataset-id&amp;gt;.json
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Pull the five most-recently-added carriers:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://data.transportation.gov/resource/az4n-8mr2.json?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;order=add_date%20DESC&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;limit=5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The annoying parts (there are three)
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;1. The column names are not what you'd guess.&lt;/strong&gt; The census predates every naming convention you like. Depending on the published extract, the phone field is &lt;code&gt;telephone&lt;/code&gt; or &lt;code&gt;phone&lt;/code&gt;, email is &lt;code&gt;email_address&lt;/code&gt;, physical state is &lt;code&gt;phy_state&lt;/code&gt;, the power-unit count is &lt;code&gt;nbr_power_unit&lt;/code&gt; (not &lt;code&gt;power_units&lt;/code&gt;), and the "when did this carrier first appear" date is &lt;code&gt;add_date&lt;/code&gt;. Before you build anything, hit the dataset's own API-docs page (linked from the portal page) or just pull one row and read its keys:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://data.transportation.gov/resource/az4n-8mr2.json?&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;limit=1"&lt;/span&gt; | python3 &lt;span class="nt"&gt;-m&lt;/span&gt; json.tool
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;2. "New carrier" is a date field, and dates on Socrata are strings.&lt;/strong&gt; Filter server-side with &lt;code&gt;$where&lt;/code&gt; against &lt;code&gt;add_date&lt;/code&gt;, ISO-formatted:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://data.transportation.gov/resource/az4n-8mr2.json?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;where=add_date%20%3E=%20'2026-06-01T00:00:00'&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;order=add_date%20DESC&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;limit=1000"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;3. Contact completeness varies by age.&lt;/strong&gt; Carriers registered recently almost always have both phone and email on file; older records have gaps. If you're building a &lt;em&gt;contactable&lt;/em&gt; lead feed, filter for the fields you actually need (&lt;code&gt;WHERE email_address IS NOT NULL&lt;/code&gt;) rather than assuming.&lt;/p&gt;

&lt;h2&gt;
  
  
  DIY: a weekly new-carrier feed in ~30 lines
&lt;/h2&gt;

&lt;p&gt;Here's a self-contained Node script: every active carrier added since a given date, in your states, with phone and email, newest first.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;DATASET&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;az4n-8mr2&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt; &lt;span class="c1"&gt;// verify via the catalog query above&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;BASE&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`https://data.transportation.gov/resource/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;DATASET&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;.json`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;newCarriers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;addedAfter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt; &lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;stateList&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;states&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="s2"&gt;`'&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;s&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="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;,&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
    &lt;span class="s2"&gt;`add_date &amp;gt;= '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;addedAfter&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T00:00:00'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`phy_state in (&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;stateList&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="s2"&gt;`telephone IS NOT NULL`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="s2"&gt;`email_address IS NOT NULL`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; AND &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;$where&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;where&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$order&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;add_date DESC&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="nx"&gt;BASE&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="nx"&gt;params&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;dotNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dot_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;legalName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;legal_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dbaName&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dba_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;phone&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;telephone&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;email&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;email_address&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;state&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;phy_state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;powerUnits&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nbr_power_unit&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;nbr_power_unit&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;addedDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;add_date&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="dl"&gt;''&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;safer&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;`https://safer.fmcsa.dot.gov/query.asp?searchtype=ANY&amp;amp;query_type=queryCarrierSnapshot&amp;amp;query_param=USDOT&amp;amp;query_string=&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dot_number&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="p"&gt;}));&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;leads&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;newCarriers&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt; &lt;span class="na"&gt;states&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;TX&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;OK&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="na"&gt;addedAfter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-06-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt; &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&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="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;length&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; contactable new carriers`&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;leads&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Cron that weekly, diff against last week's DOT numbers so you only email each carrier once, and you have a real lead pipeline for the cost of nothing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One data-source gotcha that will bite you if you don't know it:&lt;/strong&gt; FMCSA populates a carrier's &lt;strong&gt;cargo-classification flags about two months after&lt;/strong&gt; it first registers. So if you filter for, say, "refrigerated" carriers &lt;em&gt;and&lt;/em&gt; "added in the last 30 days," you'll get almost nothing — not because they don't exist, but because that field hasn't been backfilled yet. Filter on cargo type for &lt;em&gt;established&lt;/em&gt; carriers, and on &lt;code&gt;add_date&lt;/code&gt; for &lt;em&gt;fresh&lt;/em&gt; ones; don't combine the two on a tight recent window.&lt;/p&gt;

&lt;h2&gt;
  
  
  Things you'll end up building on top of it
&lt;/h2&gt;

&lt;p&gt;Within a week of using the raw feed seriously you'll want: fleet-size banding (&lt;code&gt;nbr_power_unit&lt;/code&gt; between 1 and 10 is the factoring/fuel-card sweet spot), interstate-vs-intrastate filtering, hazmat and safety-rating fields, company-officer extraction (the decision-maker's name is in there), de-duplication across weekly runs, the ~1,000-row Socrata page limit handled with &lt;code&gt;$offset&lt;/code&gt; paging, and retry/backoff. None of it is hard. All of it is maintenance you now own.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintained shortcut
&lt;/h2&gt;

&lt;p&gt;I package exactly this — the census, streamed and filtered server-side, with all the fields above normalized into clean names and the fleet/cargo/hazmat/date filters as simple inputs — as an Apify actor:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/fmcsa-motor-carrier-scraper" rel="noopener noreferrer"&gt;FMCSA Motor Carrier Scraper&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The weekly-insurance-lead query becomes:&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"states"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"OK"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"statuses"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"active"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"addedAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requireEmail"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"requirePhone"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="w"&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;Every record comes back in the same clean shape — &lt;code&gt;dotNumber&lt;/code&gt;, &lt;code&gt;legalName&lt;/code&gt;, &lt;code&gt;dbaName&lt;/code&gt;, &lt;code&gt;phone&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;companyOfficers&lt;/code&gt;, &lt;code&gt;powerUnits&lt;/code&gt;, &lt;code&gt;cargoTypes&lt;/code&gt;, &lt;code&gt;safetyRating&lt;/code&gt;, &lt;code&gt;saferUrl&lt;/code&gt; — and Apify gives you JSON/CSV/Excel export, a scheduler (run it every Monday at 6am), and webhooks into your CRM. You're charged per record returned, so a weekly pull of a few hundred fresh carriers costs about a dollar. It also handles the paging, the cargo-lag warning, and re-discovers the dataset ID if DOT re-publishes it — the babysitting I described above.&lt;/p&gt;

&lt;h2&gt;
  
  
  Which route should you take?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One state, personal prospecting&lt;/strong&gt; → the script above. It's 30 lines and the data is free.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple states, weekly scheduling, fleet/cargo filters, CRM webhooks, or feeding an AI agent&lt;/strong&gt; → the actor (it's also callable as an MCP tool via Apify, so "any new reefer carriers in Texas this week?" becomes a one-line agent query).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise BD with dialer integration and territory routing&lt;/strong&gt; → that's when the dedicated trucking-lead SaaS earns its subscription.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Either way the underlying record is a public federal registration — official, free, and yours to use. That's the part most people selling "exclusive carrier leads" would rather you didn't know.&lt;/p&gt;

&lt;p&gt;Questions about a specific census field or a state's registration volume? Drop a comment — I've spent more time in this dataset's column names than I'd like to admit.&lt;/p&gt;

</description>
      <category>opendata</category>
      <category>api</category>
      <category>leadgen</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Track SAM.gov contract opportunities without an API key (or a $300/month GovCon subscription)</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:56:01 +0000</pubDate>
      <link>https://dev.to/cblu2005/track-samgov-contract-opportunities-without-an-api-key-or-a-300month-govcon-subscription-2mmk</link>
      <guid>https://dev.to/cblu2005/track-samgov-contract-opportunities-without-an-api-key-or-a-300month-govcon-subscription-2mmk</guid>
      <description>&lt;p&gt;If you sell anything to the US federal government, you live and die by SAM.gov's Contract Opportunities — every proposed contract action over $25,000 gets posted there. The problem is &lt;em&gt;consuming&lt;/em&gt; it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;The &lt;strong&gt;website&lt;/strong&gt; is slow, aggressively bot-protected, and unusable for bulk work.&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;official API&lt;/strong&gt; requires a SAM.gov account, an API key that expires every 90 days, and has rate limits that hurt (Google "sam.gov api key" and enjoy the pain threads).&lt;/li&gt;
&lt;li&gt;The &lt;strong&gt;GovCon SaaS tools&lt;/strong&gt; that repackage this data start around $100/month and run into the thousands.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Here's the thing almost nobody seems to know: &lt;strong&gt;the sam.gov website's own search backend is a public, keyless JSON API.&lt;/strong&gt; No account. No API key. No 90-day key rotation. It's the same endpoint your browser hits when you search Contract Opportunities — it's just never advertised as an API.&lt;/p&gt;

&lt;p&gt;(You may have read older posts pointing at SAM.gov's daily CSV extract on S3 — &lt;code&gt;ContractOpportunitiesFullCSV.csv&lt;/code&gt;. That &lt;em&gt;was&lt;/em&gt; the best free path for years, but since late July 2026 SAM.gov has been regenerating it as a header-only stub: 47 column names, zero rows. If your pipeline was built on it, that's why it's suddenly empty. The search API below is the working replacement — and unlike the 200+ MB daily download, it filters server-side.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The endpoint
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sam.gov/api/prod/sgs/v1/search/
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Standard query parameters, JSON out. The important ones:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Param&lt;/th&gt;
&lt;th&gt;Meaning&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;index=opp&amp;amp;mode=search&amp;amp;responseType=json&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;boilerplate — always send these&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;page&lt;/code&gt; / &lt;code&gt;size&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;paging; &lt;code&gt;size&lt;/code&gt; max 100&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;sort=-modifiedDate&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;newest first&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;is_active=true&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;only still-open notices&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;naics&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;comma-separated NAICS codes (prefixes work)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;pop_state&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;place-of-performance states, e.g. &lt;code&gt;TX,FL&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;set_aside&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;set-aside codes (&lt;code&gt;SBA&lt;/code&gt;, &lt;code&gt;8A&lt;/code&gt;, &lt;code&gt;WOSB&lt;/code&gt;, &lt;code&gt;SDVOSBC&lt;/code&gt;...)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;publish_date.from&lt;/code&gt; / &lt;code&gt;.to&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;needs an explicit UTC offset, e.g. &lt;code&gt;2026-07-01-04:00&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;
&lt;code&gt;q&lt;/code&gt; + &lt;code&gt;qMode=ALL&lt;/code&gt;
&lt;/td&gt;
&lt;td&gt;keyword search&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Check it yourself — active custom-programming solicitations, newest first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://sam.gov/api/prod/sgs/v1/search/?index=opp&amp;amp;mode=search&amp;amp;responseType=json&amp;amp;sort=-modifiedDate&amp;amp;size=5&amp;amp;page=0&amp;amp;is_active=true&amp;amp;naics=541511"&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;-H&lt;/span&gt; &lt;span class="s2"&gt;"Accept: application/hal+json"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two things the search results &lt;em&gt;don't&lt;/em&gt; carry: the &lt;strong&gt;contracting officer's contacts&lt;/strong&gt; and the full description. Those live on a second keyless endpoint, one call per notice:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;https://sam.gov/api/prod/opps/v2/opportunities/&amp;lt;noticeId&amp;gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  DIY: a NAICS watcher in ~35 lines of Python
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="kn"&gt;import&lt;/span&gt; &lt;span class="n"&gt;requests&lt;/span&gt;

&lt;span class="n"&gt;SEARCH&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://sam.gov/api/prod/sgs/v1/search/&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;DETAIL&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;https://sam.gov/api/prod/opps/v2/opportunities/{}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="n"&gt;HEADERS&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Accept&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;application/hal+json&lt;/span&gt;&lt;span class="sh"&gt;"&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="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;index&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;opp&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;mode&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;search&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;responseType&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;json&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;sort&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;-modifiedDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&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="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;is_active&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;true&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;naics&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;541511,541512&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;            &lt;span class="c1"&gt;# custom programming, systems design
&lt;/span&gt;    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;publish_date.from&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;2026-07-01-04:00&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="n"&gt;data&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;SEARCH&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="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
    &lt;span class="n"&gt;results&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;data&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;_embedded&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;results&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;[])&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;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;results&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="c1"&gt;# contacts + description live on the detail endpoint
&lt;/span&gt;        &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;requests&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="n"&gt;DETAIL&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;format&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]),&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;HEADERS&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;timeout&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
        &lt;span class="n"&gt;data2&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;d&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;data2&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="n"&gt;poc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;next&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;iter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data2&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;pointOfContact&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="p"&gt;[]),&lt;/span&gt; &lt;span class="p"&gt;{})&lt;/span&gt;
        &lt;span class="n"&gt;due&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;data2&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;solicitation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;deadlines&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&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;response&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;
        &lt;span class="nf"&gt;print&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;r&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;publishDate&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="ow"&gt;or&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)[&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;title&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="si"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;70&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;due=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;due&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  poc=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;poc&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;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;""&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;  &lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;
              &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;https://sam.gov/opp/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;_id&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/view&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="nf"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;size&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;data&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;][&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;totalElements&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]:&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="n"&gt;params&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;page&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's a working daily bid-pipeline feed with zero accounts and zero dollars. Cron it, pipe it to Slack, done.&lt;/p&gt;

&lt;p&gt;Things you'll add within a week of using it seriously: the per-notice detail calls are 1:1 with results, so you'll want concurrency with a cap and retry/backoff (sam.gov &lt;em&gt;will&lt;/em&gt; throttle a tight loop — it's the same bot protection the website has); the API stops paging at 10,000 records per query, so broad searches need date-windowing; the date params silently return wrong windows without that UTC offset; set-aside and notice-type filtering take internal codes; and you'll want dedupe across days. None of it is hard; all of it is maintenance.&lt;/p&gt;

&lt;h2&gt;
  
  
  The maintained shortcut
&lt;/h2&gt;

&lt;p&gt;I package exactly this — the same search API the sam.gov site uses, paged, enriched with the per-notice contact details, all filters server-side, with the (currently broken) CSV extract kept as an automatic fallback source — as an Apify actor:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/sam-gov-contract-opportunities-scraper" rel="noopener noreferrer"&gt;SAM.gov Contract Opportunities Scraper&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Everything still biddable in construction trades, small-business set-aside, in Texas or Florida:&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"naicsCodes"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"236"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"237"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"238"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"setAsides"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"SBA"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"popStates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"TX"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"FL"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"postedAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-06-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"responseDueAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-11"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResults"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="w"&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;Out comes clean JSON/CSV/Excel with structured contacts, award data on award notices, and direct SAM.gov links. Schedule it daily in Apify, add a webhook, and you have the core of the $300/month GovCon alert products for about the price of a coffee per month (pricing is per record returned).&lt;/p&gt;

&lt;h2&gt;
  
  
  Which route should you take?
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;One NAICS code, personal use&lt;/strong&gt; → the Python script above. Seriously, it's ~35 lines.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Multiple filters, scheduling, teammates who want CSVs, or feeding an AI agent&lt;/strong&gt; → the actor (it's also callable as an MCP tool via Apify, which makes "any new cybersecurity RFPs today?" a one-line agent query).&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Enterprise BD with pipeline analytics&lt;/strong&gt; → that's when the SaaS subscriptions earn their keep.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The data itself is public, official, and free either way — which is exactly how government contracting data should be.&lt;/p&gt;

</description>
      <category>government</category>
      <category>data</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Building permits are the best construction leads nobody uses — here's how to pull them from official city open data</title>
      <dc:creator>Steven Carleton</dc:creator>
      <pubDate>Wed, 12 Aug 2026 13:55:56 +0000</pubDate>
      <link>https://dev.to/cblu2005/building-permits-are-the-best-construction-leads-nobody-uses-heres-how-to-pull-them-from-3m51</link>
      <guid>https://dev.to/cblu2005/building-permits-are-the-best-construction-leads-nobody-uses-heres-how-to-pull-them-from-3m51</guid>
      <description>&lt;p&gt;Every building permit is a public record of someone about to spend money on construction: the address, the type of work, often the declared project value and the contractor doing it. If you sell building materials, dumpster rentals, insurance, solar, HVAC service plans — or you &lt;em&gt;are&lt;/em&gt; a contractor watching your competitors — permits are the earliest buying signal that exists.&lt;/p&gt;

&lt;p&gt;And most US cities publish them, daily, for free, on official open-data APIs. Not scraped, not resold, not 30 days stale: the city's own database.&lt;/p&gt;

&lt;p&gt;This post shows how to pull them yourself with nothing but &lt;code&gt;curl&lt;/code&gt;, what the data looks like, where it gets annoying (every city names its columns differently), and how to automate the whole thing.&lt;/p&gt;

&lt;h2&gt;
  
  
  Where permits live: the SODA API
&lt;/h2&gt;

&lt;p&gt;Most large US cities run their open-data portals on Socrata, which exposes every dataset through the SODA API: plain HTTPS, JSON out, SQL-ish query parameters in. No API key required for moderate use.&lt;/p&gt;

&lt;p&gt;Chicago's building permits, newest first:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://data.cityofchicago.org/resource/ydr8-5enu.json?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;order=issue_date%20DESC&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;limit=5"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Filter server-side with &lt;code&gt;$where&lt;/code&gt; — say, permits issued this month:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://data.cityofchicago.org/resource/ydr8-5enu.json?&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;where=issue_date%20%3E=%20'2026-07-01T00:00:00'&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\&lt;/span&gt;&lt;span class="s2"&gt;
&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;order=issue_date%20DESC&amp;amp;&lt;/span&gt;&lt;span class="se"&gt;\$&lt;/span&gt;&lt;span class="s2"&gt;limit=100"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;You get structured JSON with the permit number, work description, fees, reported cost, and — in Chicago's case — up to five named contacts including the contractor.&lt;/p&gt;

&lt;p&gt;A few dataset IDs to get you started (all verified working as of July 2026):&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;City&lt;/th&gt;
&lt;th&gt;Portal&lt;/th&gt;
&lt;th&gt;Dataset ID&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;New York City (DOB NOW)&lt;/td&gt;
&lt;td&gt;data.cityofnewyork.us&lt;/td&gt;
&lt;td&gt;&lt;code&gt;rbx6-tga4&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Chicago&lt;/td&gt;
&lt;td&gt;data.cityofchicago.org&lt;/td&gt;
&lt;td&gt;&lt;code&gt;ydr8-5enu&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Los Angeles (2020+)&lt;/td&gt;
&lt;td&gt;data.lacity.org&lt;/td&gt;
&lt;td&gt;&lt;code&gt;pi9x-tg5x&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Austin&lt;/td&gt;
&lt;td&gt;data.austintexas.gov&lt;/td&gt;
&lt;td&gt;&lt;code&gt;3syk-w9eu&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;San Francisco&lt;/td&gt;
&lt;td&gt;data.sfgov.org&lt;/td&gt;
&lt;td&gt;&lt;code&gt;i98e-djp9&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;New Orleans&lt;/td&gt;
&lt;td&gt;data.nola.gov&lt;/td&gt;
&lt;td&gt;&lt;code&gt;72f9-bi28&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;To find any other city's dataset, query Socrata's catalog API:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;curl &lt;span class="s2"&gt;"https://api.us.socrata.com/api/catalog/v1?domains=data.brla.gov&amp;amp;q=building%20permits&amp;amp;only=datasets"&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The annoying part: every city is a special snowflake
&lt;/h2&gt;

&lt;p&gt;Here's where the DIY route gets expensive. The issue date is &lt;code&gt;issue_date&lt;/code&gt; in Chicago, &lt;code&gt;issued_date&lt;/code&gt; in San Francisco, &lt;code&gt;issuedate&lt;/code&gt; in New Orleans, and &lt;code&gt;issueddate&lt;/code&gt; in Baton Rouge. Valuation is &lt;code&gt;reported_cost&lt;/code&gt;, &lt;code&gt;estimated_cost&lt;/code&gt;, &lt;code&gt;valuation&lt;/code&gt;, &lt;code&gt;estprojectcost&lt;/code&gt;, or &lt;code&gt;declaredvaluation&lt;/code&gt; depending on who you ask. NYC's feed includes rows for permits that &lt;em&gt;aren't issued yet&lt;/em&gt;, with the date simply missing. One city publishes latitude/longitude as columns, another nests them in a GeoJSON point, a third uses a &lt;code&gt;geolocation&lt;/code&gt; object.&lt;/p&gt;

&lt;p&gt;If you only care about one city, write the mapping once and move on. If you want a multi-city feed, you're maintaining a normalization layer — and re-verifying dataset IDs whenever a city migrates portals (NYC's old &lt;code&gt;ipu4-2q9a&lt;/code&gt; dataset silently stopped updating in 2020; the live one is &lt;code&gt;rbx6-tga4&lt;/code&gt;; Seattle and Dallas left Socrata entirely).&lt;/p&gt;

&lt;p&gt;Here's a minimal normalizer for two cities to show the shape of the problem:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight javascript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;CITIES&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;chicago&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://data.cityofchicago.org/resource/ydr8-5enu.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;issue_date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;permitNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permit_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;permitType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permit_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;work_description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuedDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issue_date&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_direction&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_name&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;valuation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reported_cost&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;reported_cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;san-francisco&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;span class="na"&gt;url&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;https://data.sfgov.org/resource/i98e-djp9.json&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;issued_date&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;map&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;({&lt;/span&gt;
      &lt;span class="na"&gt;permitNumber&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permit_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;permitType&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;permit_type_definition&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;description&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
      &lt;span class="na"&gt;issuedDate&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;issued_date&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nf"&gt;slice&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="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;address&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;street_suffix&lt;/span&gt;&lt;span class="p"&gt;].&lt;/span&gt;&lt;span class="nf"&gt;filter&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nb"&gt;Boolean&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt; &lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
      &lt;span class="na"&gt;valuation&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;estimated_cost&lt;/span&gt; &lt;span class="p"&gt;?&lt;/span&gt; &lt;span class="nc"&gt;Number&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;r&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;estimated_cost&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;null&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;span class="p"&gt;};&lt;/span&gt;

&lt;span class="k"&gt;async&lt;/span&gt; &lt;span class="kd"&gt;function&lt;/span&gt; &lt;span class="nf"&gt;fetchPermits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;cityKey&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;issuedAfter&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;limit&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;city&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;CITIES&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="nx"&gt;cityKey&lt;/span&gt;&lt;span class="p"&gt;];&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;params&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;URLSearchParams&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;
    &lt;span class="na"&gt;$limit&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;String&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;limit&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="na"&gt;$order&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="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; DESC`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="na"&gt;$where&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="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;dateField&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt; &amp;gt;= '&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;issuedAfter&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T00:00:00'`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetch&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="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;url&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="nx"&gt;params&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="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;
  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="nx"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;city&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;map&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="nx"&gt;console&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;log&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;fetchPermits&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;chicago&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="s1"&gt;2026-07-01&lt;/span&gt;&lt;span class="dl"&gt;'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The shortcut: one normalized API for 9 cities
&lt;/h2&gt;

&lt;p&gt;I maintain an Apify actor that does exactly this across nine cities/counties (NYC, Chicago, LA, Austin, SF, New Orleans, Baton Rouge, Montgomery County MD, Norfolk VA), with one shared schema, server-side date filtering and full-text search, and the dataset-ID babysitting handled for you:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://apify.com/cblu/us-building-permits-scraper" rel="noopener noreferrer"&gt;US Building Permits Scraper&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;One input gets you fresh, high-value leads across cities:&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="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"cities"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"austin"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"chicago"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"issuedAfter"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"2026-07-01"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"minValuation"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"maxResultsPerCity"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="w"&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;Every record comes back in the same shape — &lt;code&gt;permitNumber&lt;/code&gt;, &lt;code&gt;permitType&lt;/code&gt;, &lt;code&gt;description&lt;/code&gt;, &lt;code&gt;issuedDate&lt;/code&gt;, &lt;code&gt;address&lt;/code&gt;, &lt;code&gt;valuation&lt;/code&gt;, &lt;code&gt;contractorName&lt;/code&gt; where the city publishes it, coordinates — and Apify hands you JSON/CSV/Excel export, scheduling (run it every Monday morning), and webhooks into your CRM for free. Pricing is per record returned, so a weekly 1,000-lead pull costs a few dollars.&lt;/p&gt;

&lt;h2&gt;
  
  
  Bonus: turn permits into &lt;em&gt;qualified&lt;/em&gt; contractor lists
&lt;/h2&gt;

&lt;p&gt;Permits tell you who's building. State license records tell you who's &lt;em&gt;licensed&lt;/em&gt; — with status, expiration date, and mailing address. Cross-referencing the two is a genuinely underrated play: match permit contractor names against the state's license roll and you get, e.g., "active Florida GCs who pulled zero permits this quarter" (prime targets for lead-gen services) or "contractors whose license expires within 90 days" (renewal/CE marketing).&lt;/p&gt;

&lt;p&gt;Florida publishes its entire license database as public CSV extracts, and there's an actor for that too: &lt;strong&gt;&lt;a href="https://apify.com/cblu/florida-license-records-scraper" rel="noopener noreferrer"&gt;Florida Contractor &amp;amp; Professional License Search (DBPR)&lt;/a&gt;&lt;/strong&gt;. A join on normalized business names between the two datasets is an afternoon of pandas and surprisingly good fun.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recap
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;City open-data portals publish building permits daily through the SODA API — free, official, structured.&lt;/li&gt;
&lt;li&gt;The pain is normalization and dataset churn, not access.&lt;/li&gt;
&lt;li&gt;DIY one city with 30 lines of code; use a maintained multi-city actor when you want breadth and scheduling without the babysitting.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Questions about a specific city's dataset? Drop a comment — I've probably already fought with its column names.&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>opendata</category>
      <category>api</category>
      <category>tutorial</category>
    </item>
  </channel>
</rss>
