<?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: Zach</title>
    <description>The latest articles on DEV Community by Zach (@zachmeditates).</description>
    <link>https://dev.to/zachmeditates</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%2F4064515%2Fbf9a8449-ecfe-465f-ac59-72468fdb4691.png</url>
      <title>DEV Community: Zach</title>
      <link>https://dev.to/zachmeditates</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/zachmeditates"/>
    <language>en</language>
    <item>
      <title>How to Get Licensed Cannabis Dispensary Data Across 47 US States (and Canada)</title>
      <dc:creator>Zach</dc:creator>
      <pubDate>Wed, 05 Aug 2026 16:58:14 +0000</pubDate>
      <link>https://dev.to/zachmeditates/how-to-get-licensed-cannabis-dispensary-data-across-47-us-states-and-canada-5bdc</link>
      <guid>https://dev.to/zachmeditates/how-to-get-licensed-cannabis-dispensary-data-across-47-us-states-and-canada-5bdc</guid>
      <description>&lt;p&gt;I spent way too long once trying to build a single clean list of licensed cannabis dispensaries. It sounds like it should be easy. It is not.&lt;/p&gt;

&lt;p&gt;Every state runs its own licensing portal. Some hand you a tidy CSV. Some give you an HTML table that looks like it was last touched in 2011. And a few have quietly moved their data somewhere else, so the old URL still returns a 200, just with nothing in it. Then you have the consumer platforms, Weedmaps and Leafly and Dutchie, each covering part of the market with their own duplicates and their own missing fields. No single source has the whole picture, and the pieces do not line up cleanly.&lt;/p&gt;

&lt;p&gt;Here is the actual shape of the problem, and how I deal with it now without babysitting a pile of scrapers.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two kinds of source, neither one enough
&lt;/h2&gt;

&lt;p&gt;There are really two buckets of data here, and each is missing exactly what the other one has.&lt;/p&gt;

&lt;p&gt;The official state license registries are the source of truth. They carry the license number, the license status, and the issue and expiry dates. That is the stuff you cannot get anywhere else. The catch is that it lives across roughly 47 separate portals in wildly different shapes, and, as I mentioned, some of them have gone dark without telling anyone.&lt;/p&gt;

&lt;p&gt;The platforms are the opposite. Broad coverage, decent contact details, hours, ratings. No license data at all, and plenty of the same store listed three times.&lt;/p&gt;

&lt;p&gt;The dataset you actually want is the merge of both, deduplicated, with some honest signal about which states have real license data on any given day. That last part matters more than it sounds, and I will come back to it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Pulling the data
&lt;/h2&gt;

&lt;p&gt;Rather than rebuild the scraper every time a portal changes its markup, I run the [Cannabis Dispensary Monitor](&lt;a href="https://apify.com/" rel="noopener noreferrer"&gt;https://apify.com/&lt;/a&gt;&lt;br&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%2Fov69auxmw74o7o50vwqn.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%2Fov69auxmw74o7o50vwqn.png" alt=" " width="800" height="513"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;/cannabis-dispensary-scraper) on Apify. It merges the official registries from 47 US states plus all of Canada with the platforms, dedupes, and reports per-state coverage.&lt;/p&gt;

&lt;p&gt;Kick off a run and grab the dataset from the 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="nt"&gt;-X&lt;/span&gt; POST &lt;span class="s2"&gt;"https://api.apify.com/v2/acts/registryfeeds~cannabis-dispensary-scraper/runs?token=YOUR_TOKEN"&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;'{ "states": ["CA", "CO", "FL"], "maxResultsPerSource": 100 }'&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every record comes back on one schema, no matter which source it came from:&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;"name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Native Roots Speer"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"address"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"city"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Denver"&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;"CO"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"zip"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"80211"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"coordinates"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lat"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;39.75&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nl"&gt;"lng"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;-105.00&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"licenseNumber"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"402-00913"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"licenseStatus"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&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;"categories"&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;"recreational"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"medical"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"sources"&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;"state-co"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"leafly"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If you just want to see the shape before committing to anything, there is a free tier, and Test mode caps a run to a few cents.&lt;/p&gt;

&lt;h2&gt;
  
  
  The parts people skip
&lt;/h2&gt;

&lt;p&gt;A one-time export is already stale by next week, so two things make this usable in production.&lt;/p&gt;

&lt;p&gt;The first is change detection. Every run diffs against the last snapshot and emits events for new stores, closures, and license suspensions. If you are doing anything in B2B sales, compliance, or real estate, a newly licensed dispensary is about as high-intent a signal as you get.&lt;/p&gt;

&lt;p&gt;The second is the coverage honesty thing I flagged earlier, and it is the feature I am most attached to because it burned me before I built it. States keep pulling their license data off open portals. When that happens, a naive scraper returns an empty page and you ship a spreadsheet full of blanks without noticing. So the run checks itself. If a big market like Oklahoma comes back with basically no official records, it does not shrug and move on. It marks the state &lt;code&gt;below_expected&lt;/code&gt; and drops it into &lt;code&gt;statesWithIncompleteLicenseData&lt;/code&gt;, so you know which states have trustworthy license fields this run instead of finding out from an angry customer.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wiring it into something useful
&lt;/h2&gt;

&lt;p&gt;Data sitting in a dataset is not doing anything for you. The move is to route it automatically.&lt;/p&gt;

&lt;p&gt;Set a &lt;code&gt;webhookUrl&lt;/code&gt; and the run POSTs those change events in real time, so you can point them at Slack, your own API, or a no-code trigger. Apify also ships official connectors for Make, Zapier, and n8n, which means you can fire a scenario when a run finishes and drop the results into Sheets, Airtable, or a CRM without touching any glue code.&lt;/p&gt;

&lt;p&gt;The recipe I actually use: a weekly scheduled run, a webhook on every new store, and a Make scenario that appends the row to a Google Sheet and pings a Slack channel. That is the whole pipeline.&lt;/p&gt;

&lt;h2&gt;
  
  
  When you should just do it yourself
&lt;/h2&gt;

&lt;p&gt;I am not going to pretend this is always the answer. If you need one state, one time, write a quick script against that state's portal and move on. It is not worth outsourcing.&lt;/p&gt;

&lt;p&gt;The calculus flips the moment you need several states at once, license status you can actually trust, deduplication across platforms, and a record of what changed over time. That is where the maintenance tax on a homegrown version gets real. Portals move, formats drift, platforms start rate-limiting you, and suddenly you are maintaining a scraper farm instead of building your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  Try it
&lt;/h2&gt;

&lt;p&gt;The Actor is &lt;a href="https://apify.com/registryfeeds/cannabis-dispensary-scraper" rel="noopener noreferrer"&gt;here&lt;/a&gt;. Free tier, Test mode for a cheap look at the schema.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://www.youtube.com/watch?v=CnjX_MoOx-M" rel="noopener noreferrer"&gt;https://www.youtube.com/watch?v=CnjX_MoOx-M&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Same pattern works for liquor licenses, restaurant inspections, and professional licenses, which is basically the other half of what I build. Official source plus platforms, merged and monitored. If you end up trying it, I would genuinely like to hear where it falls short, so leave a comment.&lt;/p&gt;

</description>
      <category>webscraping</category>
      <category>data</category>
      <category>opendata</category>
      <category>api</category>
    </item>
  </channel>
</rss>
