fuzzy

Live dispensary data,

built to be called, not scraped.

A public REST API, an open-source React kit, and an MCP server for agents, all reading the same live menus Fuzzy scans on Dutchie and Jane nationwide, plus Weedmaps in California.

  • REST API, no key required
  • Open-source React kit
  • MCP server for agents

Overview

Three surfaces read the same live dispensary menu data: a REST API for your own code, an open-source React kit if you also want the UI, and an MCP server for agents. No API key on any of them today — that's changing, see API keys below. See the data hub for who builds on this and why.

Honest limits of this data

  • Three platforms, unevenly. Fuzzy reads live dispensary menus on Dutchie and Jane nationwide, and on Weedmaps in California only. Not Leafly, not a store's own site, and not Weedmaps outside California yet. A door count from this data is a floor: could be more, we just haven't found it yet, and outside California a Weedmaps-only store is invisible to us.
  • “Current” means the last completed scrape of that retailer, not a live point-of-sale feed. A store that hasn't been re-scraped since a product sold out will still show it as carried until the next scrape catches up.
  • lastSeenAt is when that listing was last scraped present on the menu, not when it was first listed and not a freshness guarantee beyond “as of our last pass.”
  • inStock: null means the retailer's platform doesn't expose inventory counts, not that stock is unknown to be positive. We never guess a number we don't have.
  • Scrapes run on their own cadence, not a fixed calendar day, so two calls minutes apart can return identical data.

What teams do with this

Three worked examples on Heavy Hitters, pulled live from the API above.

A “shop by product” picker on a brand's site

Top products ranks a brand's SKUs by how many doors carry them — feed it straight into a listicle or a product-picker instead of hand-updating one.

curl "https://api.fuzzy.so/api/radar/brands/heavy-hitters/products/top?limit=3"
{
  "products": [
    {
      "name": "Acapulco Gold | Ultra Extract High Purity Oil - 1G Vape",
      "doors": 94,
      "priceRange": { "min": "31.80", "median": "60", "max": "86.00" },
      "thc": "88.9",
      "category": "vape",
      "deepLink": "https://dutchie.com/dispensary/strain-stars/..."
    },
    { "name": "Pineapple Express | Ultra Extract...", "doors": 92 },
    { "name": "Blueberry Blitz | Ultra Pure Gummies...", "doors": 87 }
  ],
  "total": 3902
}

Replacing a stale “find a store” page

A store list that someone updates by hand goes stale the day a retailer drops the brand. The kit reads the same live data every page load.

import { WhereToBuy } from "@fuzzylabs/where-to-buy";

// Replaces a hand-maintained "find a store" list with the current one —
// deep links, stock state, and product picker included.
function StoreLocator() {
  return <WhereToBuy brand="heavy-hitters" site="yourbrand.com" />;
}

“Which doors carry my competitor and not me” in a chat client

The MCP whitespace tool answers a sales question in one call — no dashboard, no export, just the doors and how many.

fuzzy_brand_whitespace({ brand: "heavy-hitters", competitor: "stiiizy" })
{
  "whitespaceDoors": 1164,
  "targetDoors": 608,
  "competitorDoors": 1614,
  "byState": [
    { "regionCode": "CA", "doors": 390 },
    { "regionCode": "MI", "doors": 320 },
    { "regionCode": "NV", "doors": 147 }
  ],
  "examples": [
    { "name": "BudBees", "city": "Mission Hills", "regionCode": "CA" }
  ]
}

React widget

@fuzzylabs/where-to-buy is an open-source kit on npm that drops a store locator onto a brand's own site, reading the same data as the API below.

import { WhereToBuy } from "@fuzzylabs/where-to-buy";

function Page() {
  return <WhereToBuy brand="heavy-hitters" state="CA" />;
}

The kit's useTopProducts hook wraps the top-products endpoint below, and <WhereToBuy> uses it automatically for its product picker. It also injects the schema.org JSON-LD below on its own; opt out with includeJsonLd=false.

MCP

The same Radar data is available as an MCP server, for agent platforms and Claude Code sessions that want name-to-entity lookup, verified links, and bounded-token stats instead of raw REST calls. Full tool list, what each one answers, and how to connect a client live on the MCP page. The endpoint is https://api.fuzzy.so/mcp, no credential and no account.

API

Where-to-buy

GET /api/radar/brands/{slug}/where-to-buy returns the stores currently carrying a brand, or one specific product with ?product=. No API key. CORS-open, so you can call it straight from your own site's browser code.

const res = await fetch(
  "https://api.fuzzy.so/api/radar/brands/heavy-hitters/where-to-buy?state=CA"
);
const { stores, total } = await res.json();

Filters: state, city (raw retailer city, not yet normalized), page/limit (default 25, max 100). Pass ?site=yoursite.com or an X-Fuzzy-Site header so we know who's using it.

Top products

GET /api/radar/brands/{slug}/products/top ranks a brand's products by door count, with price range, THC, and a deep link per product. Good for a “shop by product” picker, feeding a product_cluster_id into where-to-buy above.

curl "https://api.fuzzy.so/api/radar/brands/heavy-hitters/products/top?state=CA&limit=10"

Schema.org JSON-LD

GET /api/radar/brands/{slug}/schema.jsonld?product= returns a ready-made schema.org Product + AggregateOffer, with up to 100 per-store Offer entries (price, availability, seller, deep link). offerCount always carries the true number of stores, even when more than 100 carry it. It reads the exact same data as where-to-buy above, so the two never disagree.

<script
  type="application/ld+json"
  src="https://api.fuzzy.so/api/radar/brands/heavy-hitters/schema.jsonld?product=<clusterId>"
/>

Endpoints

The rest of the public API surface — everything under /api/radar takes no API key. Every response carrying an issue rate reports it as a 0-1 fraction, not a percentage.

GET /api/radar/brands — search or list brands. Filters: search, platform, state, category, sort/order, page/limit.

curl "https://api.fuzzy.so/api/radar/brands?search=heavy&limit=2"

GET /api/radar/brands/{slug} — full brand detail: totals, issue breakdown, per-state market breakdown, per-platform breakdown, field completeness.

curl "https://api.fuzzy.so/api/radar/brands/heavy-hitters"

GET /api/radar/retailers/{slug} — a retailer's menu: which brands it carries, product counts, average price, issue breakdown.

curl "https://api.fuzzy.so/api/radar/retailers/dutchie-fabulous-creations"

GET /api/radar/operators and GET /api/radar/operators/{slug} — multi-location operators: house brands, retail banners, location count, ghost rate.

curl "https://api.fuzzy.so/api/radar/operators?limit=1"

Errors use RFC 7807 Problem Details — a consistent JSON shape on every error response, not a mix of plain text and ad-hoc JSON.

curl "https://api.fuzzy.so/api/radar/brands/not-a-real-brand"

{
  "type": "https://fuzzy.dev/errors/not-found",
  "title": "Brand not found",
  "status": 404,
  "instance": "http://api.fuzzy.so/api/radar/brands/not-a-real-brand"
}

API keys

Everything on this page works with no key today. That's ending: a key will be required for direct access to /api/radar/* soon — no date promised, since the flip is manual — while app.fuzzy.so stays free to browse either way. Get ahead of it now: create an account and you land on Settings → API Keys, where the key is made. Already have an account? Sign in at app.fuzzy.so instead. Then send the key on every request:

curl "https://api.fuzzy.so/api/radar/brands/heavy-hitters" \
  -H "X-API-Key: fz_live_..."
  • Scopes. A new key defaults to read:radar and read:movement. read:radar covers everything documented on this page; read:movement additionally unlocks door-level movement data on brand pages. Uncheck what you don't need when you create the key.
  • Expiry. Optional. Set an expiration date on a key when you create it, or leave it open-ended.
  • Per-key rate limits. Once the key requirement is live, each key gets its own ceiling — a sane default, or a tighter one you set yourself at creation. Not enforced yet.
  • Last used. A key's row in the dashboard shows when it last authenticated a request, so a stale key is obvious before you revoke it.

Rate limits

No hard rate limit on these endpoints, no API key required. Where-to-buy, top products and schema.jsonld carry a Cache-Control header (60 seconds on where-to-buy, 5 minutes on the other two); a CDN or your own fetch layer respecting it keeps you well clear of any limit there. The brand/retailer/operator search and detail endpoints below don't set one today, so cache those yourself if you're calling them in a loop. Once a key is required (see API keys above), a keyed request also gets its own per-key ceiling on top of caching.