<?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>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>
