Products Endpoint
Search for cannabis products across thousands of dispensaries. Filter by location, category, brand, potency, price, and more.
Use V2 (/v2/products) for new integrations. V2 uses comma-separated list parameters, supports per_page and terpene profiles, and is the actively maintained endpoint. V1 remains available but is no longer recommended. The price and THC range filters (price_min, price_max, thc_min, thc_max) are currently V1-only; see V1 Legacy Format.
Request
GET https://api.cannmenus.com/v2/products
V1 is also available at /v1/products — see V1 Legacy Format below.
Core Parameters
- Name
states- Type
- string
- Description
US state(s) or Canadian province(s) to search, comma-separated. Use full names (e.g., "California", "New Jersey", "Ontario"), not abbreviations like "NJ". The parameter is
states(plural);stateis not a Products parameter. Optional in V2 (but recommended for performance). Required in V1 unlesslat,lng, anddistanceare all provided.states=California states=California,Oregon states=New%20Jersey
On V2, list parameters (states, category, tags, brands, retailers, skus) take a single comma-separated value, such as tags=Indica,Indoor. Don't repeat the parameter: V2 keeps only the last value, so tags=Indica&tags=Indoor filters on Indoor alone. Repeated parameters are V1 syntax (see V1 Legacy Format).
Location Parameters
Narrow results by geographic area:
- Name
lat- Type
- number
- Description
Latitude coordinate for location-based search. Must be used with
lng.
- Name
lng- Type
- number
- Description
Longitude coordinate for location-based search. Must be used with
lat.
- Name
distance- Type
- number
- Description
Radius in miles from the lat/lng point. Optional.
Product Filters
- Name
q- Type
- string
- Description
Text search on product name and brand name. Use the dedicated parameters (
category,tags,skus, location) to filter on other fields.q=blue dream
- Name
category- Type
- string
- Description
Product category. See Categories reference for valid values.
category=Flower category=Edible category=Vape
- Name
subcategory- Type
- string
- Description
Product subcategory. See Subcategories reference for valid values.
subcategory=Gummy subcategory=Cartridge
- Name
tags- Type
- string
- Description
Product tags for detailed filtering, comma-separated. Products matching any listed tag are returned. See Tags reference for valid values.
tags=Live%20Resin tags=Indica,Sugar%20Free
- Name
brand_name- Type
- string
- Description
Filter by brand name. Partial matching supported.
brand_name=Stiiizy brand_name=Cookies
- Name
brands- Type
- string
- Description
Filter by brand IDs, comma-separated (faster than
brand_name). Get IDs from the Brands endpoint.brands=789 brands=789,1042
- Name
retailers- Type
- string
- Description
Filter to specific retailer IDs, comma-separated. Use the Retailers endpoint to find IDs.
retailers=10600 retailers=10600,10601
- Name
product_name- Type
- string
- Description
Search product names. Partial matching supported.
product_name=Blue%20Dream
- Name
skus- Type
- string
- Description
Filter by specific Cann SKU IDs, comma-separated.
skus=SKU123 skus=SKU123,SKU456
- Name
display_weight- Type
- string
- Description
Filter by exact product weight/size.
display_weight=3.5g
- Name
quantity_per_package- Type
- number
- Description
Filter by package quantity (e.g., number of gummies in a pack).
quantity_per_package=10
- Name
menu_provider- Type
- string
- Description
Filter by menu data source. Options:
Dutchie,Weedmaps,Leafly,IHeartJane.menu_provider=Dutchie
Potency Filters
Filter by exact cannabinoid content:
- Name
percentage_thc- Type
- number
- Description
Filter by exact THC percentage. For flower, concentrates, vapes.
percentage_thc=25.5
- Name
percentage_cbd- Type
- number
- Description
Filter by exact CBD percentage.
percentage_cbd=1.0
- Name
mg_thc- Type
- number
- Description
Filter by exact THC milligrams. For edibles, tinctures, topicals.
mg_thc=100
- Name
mg_cbd- Type
- number
- Description
Filter by exact CBD milligrams.
mg_cbd=50
Price Filter
- Name
latest_price- Type
- number
- Description
Filter by exact product price in dollars.
latest_price=45.00
Menu Type
- Name
recreational- Type
- boolean
- Description
Filter to recreational products only.
recreational=true
- Name
medical- Type
- boolean
- Description
Filter to medical products only.
medical=true
Inventory & Source Filters
- Name
include_below_threshold- Type
- boolean
- Description
Whether to include listings flagged
is_below_threshold=true. Default:true. Set tofalseto exclude them entirely. Each returned product carries the boolean on its listing so you can surface an in-store-only warning in your UI.What the flag means: the listing has been removed from the partner's online ecommerce menu (e.g. Dutchie) but the product may still have in-store inventory. The
urlreturned for these listings is typically a dead link.UX guidance: if you render these listings, do NOT link the
urlwithout a clear warning such as "In-store only — removed from online menu." Consider replacing the external CTA with a non-clickable label so users aren't sent to a broken ecommerce page.include_below_threshold=false
- Name
include_all_menu_providers- Type
- boolean
- Description
Include listings from all menu data sources. Default:
false(returns only the best source per retailer for faster results).include_all_menu_providers=true
Terpene Profiles
Terpene data is opt-in. Add include_terpenes=true to a /v2/products or /v1/products request to get each listing's terpene profile.
Every product in a response includes terpene_profile, terpene_source, and terpene_source_product_id. Without include_terpenes=true, all three are always null. That null means terpenes weren't requested, not that the product has no terpene data.
- Name
include_terpenes- Type
- boolean
- Description
Return terpene profiles. Default:
false. Withtrue, each listing that has a measured profile gets aterpene_profileobject andterpene_source: "exact". Listings without a profile returnnullin both fields.include_terpenes=true
- Name
include_terpene_fallback- Type
- boolean
- Description
Applies only with
include_terpenes=true. Default:false. When a listing has no profile of its own, the API looks for one on another listing of the same product (the same Meta SKU) in the same state. A profile found this way hasterpene_source: "uuid_match", andterpene_source_product_idholds the ID of the listing it came from. That listing may be at a different store, so its profile describes the same product but not necessarily the same batch.include_terpenes=true&include_terpene_fallback=true
The fallback only fills listings that have no profile of their own, and only from listings in the same state. A listing with no such match keeps terpene_profile: null. To see where a profile came from, read terpene_source on each listing.
Terpene Response Fields
| Field | Type | Description |
|---|---|---|
terpene_profile | object or null | Maps each terpene name to its concentration as a fraction of product weight (0 to 1). Multiply by 100 for a percentage: 0.0052 is 0.52%. null when the listing has no profile, or when include_terpenes isn't set. |
terpene_source | string or null | "exact": measured on this listing. "uuid_match": taken from another listing of the same product in the same state (only with include_terpene_fallback=true). null when no profile was found for the listing, or when include_terpenes isn't set. |
terpene_source_product_id | number or null | The ID of the listing the profile came from when terpene_source is "uuid_match". Otherwise null, including on every "exact" profile. |
Reading a profile:
- Keys are terpene names as published on the dispensary's menu, such as
Myrcene,Limonene,Beta-Caryophyllene,Alpha-Pinene, andLinalool. Names aren't fully standardized across sources (bothBisabololandAlphaBisabololappear, for example), so normalize names before you aggregate across products. - A profile lists only the terpenes with a reported value for that listing. A terpene that's missing from the object has no value in the response; don't treat it as zero.
- Profiles belong to listings, not products. The same SKU at two stores can have slightly different profiles, and one store's listing can have a profile while another's has none.
Coverage
Most listings don't have a terpene profile, so many rows, sometimes a whole page, come back with terpene_profile: null even when the request is correct. Profiles come from lab results that dispensaries publish on their online menus. Coverage depends on menu provider, state, and category, and it changes as dispensaries update their menus.
Menu provider. Profiles currently come only from listings whose menu_provider is Dutchie or IHeartJane. Listings from other providers, including Weedmaps, have no terpene data. In some states, such as California and Colorado, even Dutchie listings rarely carry profiles.
State. Based on a sample of in-stock listings taken in October 2026:
| Coverage | States and provinces |
|---|---|
| Higher | Connecticut, Maryland, Nevada, New Jersey, New York, Ohio, Pennsylvania |
| Moderate | Massachusetts, Michigan, Missouri |
| Low | Arizona, Florida, Illinois, Mississippi, Oklahoma |
| Rare or none | Alaska, California, Colorado, Maine, New Mexico, Oregon, Washington, and the Canadian provinces sampled (Alberta, British Columbia, Manitoba, Newfoundland and Labrador, Ontario, Saskatchewan) |
Even in the higher-coverage states, many listings have no profile. States that aren't listed had too few listings in the sample to rate.
Category. Flower and concentrates have profiles most often, followed by vapes and pre-rolls. Tinctures, topicals, and edibles rarely have one, and Other (accessories) almost never.
Getting More Terpene Data
- Filter to the providers that publish terpenes. Add
menu_provider=Dutchieormenu_provider=IHeartJane.menu_providertakes one value per request and is case-sensitive:menu_provider=dutchiereturns no results. - Request the categories that carry profiles:
Flower,Concentrate,Vape, andPre-roll. - Don't judge coverage from one page. Results aren't ordered by data completeness, so one page can be all
nulleven in a higher-coverage state. Check several pages, or filter by provider, before you conclude a market has no terpene data. - Add
include_terpene_fallback=true. Listings without a profile of their own can take one from another listing of the same product in the same state. - Expect new listings to lag. Terpene profiles are refreshed on their own schedule, less often than menu listings, and there is no guaranteed refresh interval. A product that just appeared on a menu can show
nulluntil the next terpene refresh.
Terpene profiles aren't available from:
- The Meta SKU endpoints,
/v1/products/metaand/v2/products/meta.
Terpene Examples
Flower in Maryland with terpene profiles:
curl "https://api.cannmenus.com/v2/products?states=Maryland&category=Flower&per_page=20&include_terpenes=true" \
-H "X-Token: YOUR_API_TOKEN"
Only Dutchie listings, which carry most terpene data:
curl "https://api.cannmenus.com/v2/products?states=Maryland&category=Flower&menu_provider=Dutchie&per_page=20&include_terpenes=true" \
-H "X-Token: YOUR_API_TOKEN"
Only IHeartJane listings, in Nevada:
curl "https://api.cannmenus.com/v2/products?states=Nevada&category=Flower&menu_provider=IHeartJane&per_page=20&include_terpenes=true" \
-H "X-Token: YOUR_API_TOKEN"
Both flags, for pre-rolls in New Jersey:
curl "https://api.cannmenus.com/v2/products?states=New%20Jersey&category=Pre-roll&per_page=20&include_terpenes=true&include_terpene_fallback=true" \
-H "X-Token: YOUR_API_TOKEN"
The same Dutchie request on V1:
curl "https://api.cannmenus.com/v1/products?states=Maryland&category=Flower&menu_provider=Dutchie&per_page=20&include_terpenes=true" \
-H "X-Token: YOUR_API_TOKEN"
A listing from the Dutchie request (abridged):
{
"retailer_id": "3511",
"sku": "00fkdAaOGVYObviGUYD5ffRp",
"products": [
{
"brand_name": "Curio Wellness",
"product_name": "Hot Wing Baby Buds",
"category": "Flower",
"menu_provider": "Dutchie",
"terpene_profile": {
"Limonene": 0.0094,
"Myrcene": 0.0052,
"Beta-Caryophyllene": 0.0033,
"Linalool": 0.0024,
"Beta-Pinene": 0.0012
},
"terpene_source": "exact",
"terpene_source_product_id": null
}
]
}
Print the top three terpenes, as percentages, for each listing that has a profile:
import requests
resp = requests.get(
"https://api.cannmenus.com/v2/products",
headers={"X-Token": "YOUR_API_TOKEN"},
params={
"states": "Maryland",
"category": "Flower",
"menu_provider": "Dutchie",
"per_page": 20,
"include_terpenes": "true",
},
)
resp.raise_for_status()
for entry in resp.json()["data"]:
for listing in entry["products"]:
profile = listing["terpene_profile"]
if not profile:
continue # no measured profile for this listing
top = sorted(profile.items(), key=lambda kv: kv[1], reverse=True)[:3]
print(
listing["product_name"],
listing["terpene_source"],
", ".join(f"{name} {value * 100:.2f}%" for name, value in top),
)
Pagination
- Name
page- Type
- number
- Description
Page number to retrieve. Default:
1.
- Name
per_page- Type
- number
- Description
Results per page. Default:
20. Max:200. Use higher values to reduce total API calls for bulk pulls.per_page=200
Example Requests
Basic Search
Search for flower in California:
curl "https://api.cannmenus.com/v2/products?states=California&category=Flower&page=1" \
-H "X-Token: YOUR_API_TOKEN"
Location-Based Search
Find edibles within 5 miles of Denver:
curl "https://api.cannmenus.com/v2/products?states=Colorado&category=Edible&lat=39.7392&lng=-104.9903&distance=5&page=1" \
-H "X-Token: YOUR_API_TOKEN"
Search by Brand Name
Find Stiiizy products in California:
curl "https://api.cannmenus.com/v2/products?states=California&brand_name=Stiiizy&page=1" \
-H "X-Token: YOUR_API_TOKEN"
Text Search
Search for "blue dream" in product and brand names:
curl "https://api.cannmenus.com/v2/products?states=Washington&q=blue%20dream&page=1" \
-H "X-Token: YOUR_API_TOKEN"
Multiple Filters
Live resin vapes from a specific brand in Nevada:
curl "https://api.cannmenus.com/v2/products?states=Nevada&category=Vape&brand_name=Stiiizy&tags=Live%20Resin&page=1" \
-H "X-Token: YOUR_API_TOKEN"
Response
The terpene fields in this example are filled because the request set include_terpenes=true; without it they're null.
{
"data": [
{
"retailer_id": "10600",
"sku": "07A0Sbb8OieeaR8rIW6MVHRw",
"products": [
{
"cann_sku_id": "07A0Sbb8OieeaR8rIW6MVHRw",
"brand_name": "Cookies",
"brand_id": 1042,
"url": "https://dutchie.com/store/example/product/gary-payton",
"image_url": "https://images.dutchie.com/products/gary-payton.jpg",
"raw_product_name": "Cookies - Gary Payton 3.5g",
"product_name": "Gary Payton",
"raw_weight_string": "3.5g",
"display_weight": "3.5g",
"raw_product_category": "Flower",
"category": "Flower",
"raw_subcategory": "Flower",
"subcategory": null,
"product_tags": ["Hybrid", "Indoor"],
"percentage_thc": 28.5,
"percentage_cbd": 0.1,
"cbd_thc_ratio": "20:1",
"mg_thc": null,
"mg_cbd": null,
"quantity_per_package": 1,
"medical": false,
"recreational": true,
"latest_price": 55.00,
"original_price": 65.00,
"menu_provider": "Dutchie",
"is_below_threshold": false,
"is_stock_image": false,
"terpene_profile": {
"Myrcene": 0.0089,
"Limonene": 0.0072,
"Beta-Caryophyllene": 0.0045
},
"terpene_source": "exact",
"terpene_source_product_id": null
}
]
}
],
"pagination": {
"total_records": 1250,
"current_page": 1,
"total_pages": 63,
"next_page": 2,
"prev_page": null
}
}
Response Fields
| Field | Type | Description |
|---|---|---|
retailer_id | string | Unique dispensary identifier |
sku | string | Unique product variant identifier |
products | array | Listings for this SKU (may include multiple menu providers) |
cann_sku_id | string | Same as parent sku |
brand_name | string | Brand name |
brand_id | number | Unique brand identifier |
url | string | Direct link to product on dispensary menu |
image_url | string | Product image URL |
raw_product_name | string | Original product name from menu provider |
product_name | string | Normalized product name |
display_weight | string | Product size/weight |
category | string | Normalized category |
subcategory | string | Normalized subcategory (if applicable) |
product_tags | array | Applied product tags |
percentage_thc | number | THC percentage (for flower, concentrates) |
percentage_cbd | number | CBD percentage |
cbd_thc_ratio | string | CBD:THC ratio (e.g. "1:1", "2:1", "20:1"). Null if unspecified. |
mg_thc | number | THC milligrams (for edibles, tinctures) |
mg_cbd | number | CBD milligrams |
latest_price | number | Current listed (shelf) price, as shown on the menu at the last refresh. Deals applied at checkout (discount_in_price: "cart" in the MCP deal tools) are not included. |
original_price | number | Compare-at price from the menu; it may equal latest_price or be null. The listing is on sale when original_price is present and greater than latest_price. null means no compare-at price was listed, not that no deal exists. Deal terms are available through the MCP server; see Deals & Promotions. |
menu_provider | string | Source platform (Dutchie, Weedmaps, Leafly, IHeartJane) |
is_below_threshold | boolean | true when the listing has been removed from the partner's online ecommerce menu but the product may still be in-store stock. The url for such listings is typically a dead link — do not link directly to it without a clear warning (e.g. "In-store only — removed from online menu"). Use include_below_threshold=false to exclude these listings entirely. |
is_stock_image | boolean | true if the product image is a stock/placeholder photo. V1 only — not included in V2 responses. |
terpene_profile | object | Terpene concentrations as fractions of product weight (e.g., {"Myrcene": 0.0089} is 0.89% myrcene). Always null unless the request sets include_terpenes=true, and null for listings without a profile. See Terpene Profiles for coverage. |
terpene_source | string | exact = measured on this listing, uuid_match = taken from another listing of the same product in the same state (only with include_terpene_fallback=true). null when no profile was found or include_terpenes isn't set. |
terpene_source_product_id | number | When terpene_source is uuid_match, the ID of the listing the profile came from. Otherwise null. |
recreational | boolean | Available for recreational purchase |
medical | boolean | Available for medical purchase |
Data Freshness & History
Product data is refreshed from dispensary menus periodically throughout the day; there is no guaranteed refresh interval. Each response is a snapshot of the most recent menu data. The REST API does not return past prices or past availability.
For history, use the MCP server (tool availability depends on your plan):
get_product_events: stock and price events, such as stockouts, restocks, and price changesdeal_history: past deals and promotions (see Deals & Promotions)
Best Practices
Use Location Filters for Performance
Queries with lat/lng are faster and return more relevant results:
# Faster - location-scoped
?states=California&lat=34.0522&lng=-118.2437&distance=10
# Slower - state-wide scan
?states=California
Use IDs Instead of Text Search
Use brands and retailers with numeric IDs instead of brand_name for significantly faster queries:
# Faster - ID-based
?states=California&brands=789
# Slower - text search
?states=California&brand_name=Stiiizy
Paginate Through All Results
Don't assume all results fit on one page. Always check pagination.next_page:
all_products = []
page = 1
while True:
response = requests.get(
f"{API_URL}/products",
headers=headers,
params={"states": "California", "category": "Flower", "page": page, "per_page": 200}
)
data = response.json()
all_products.extend(data["data"])
if data["pagination"]["next_page"] is None:
break
page += 1
Cache Retailer IDs
If you frequently query products from the same dispensaries, cache their retailer IDs and use the retailers parameter for faster lookups.
Use per_page=200 for Bulk Pulls
The default is 20 results per page. Set per_page=200 to reduce total API calls by 10x:
?states=Colorado&per_page=200&page=1
Always Filter Medical vs Recreational
For states with adult-use/recreational markets (CA, CO, IL, MI, MA, NV, AZ, NJ, etc.), always set recreational=true to avoid double-counting from shared med/rec inventory systems. For medical-only states (FL, PA, OK), use medical=true.
# Recreational state
?states=California&recreational=true
# Medical-only state
?states=Florida&medical=true
V1 Legacy Format
V1 is maintained for backward compatibility but is no longer recommended for new integrations. Use V2 (/v2/products) instead.
V1 uses repeated query parameters instead of comma-separated strings:
GET https://api.cannmenus.com/v1/products
| V2 (Recommended) | V1 (Legacy) |
|---|---|
states=California,Colorado | states=California&states=Colorado |
brands=123,456 | brands=123&brands=456 |
retailers=100,200 | retailers=100&retailers=200 |
tags=Indica,Live | tags=Indica&tags=Live |
category=Flower,Edible | category=Flower (single value only) |
V1 returns the same response format and supports per_page and terpene profiles. It differs from V2 in three ways:
- List parameters are repeated instead of comma-separated (table above).
statesis required unlesslat,lng, anddistanceare all provided.- V1 adds inclusive range filters that V2 does not currently support:
- Name
price_min- Type
- number
- Description
Minimum
latest_price, inclusive. V1 only.price_min=20
- Name
price_max- Type
- number
- Description
Maximum
latest_price, inclusive. V1 only.price_max=40
- Name
thc_min- Type
- number
- Description
Minimum total THC percentage, inclusive. V1 only.
thc_min=20
- Name
thc_max- Type
- number
- Description
Maximum total THC percentage, inclusive. V1 only.
thc_max=30
Meta SKU Endpoint
The meta endpoint tags each listing with its Meta SKU (a UUID that identifies the same product across different retailers) instead of grouping by retailer SKU. Results are sorted by meta_sku, so listings of the same product at different retailers sit next to each other. This is useful for cross-retailer product matching and deduplication.
GET https://api.cannmenus.com/v2/products/meta
The meta endpoint uses the same V1-style repeated query parameters (not comma-separated) even on the V2 path. states is required.
Parameters
The meta endpoint accepts a subset of the main products parameters:
- Location:
lat,lng,distance states(required)- IDs:
retailers,brands - Text:
brand_name,product_name - Product:
display_weight,category(single value),subcategory,tags,quantity_per_package - Potency:
percentage_thc,percentage_cbd,mg_thc,mg_cbd - Price:
latest_price - Source and menu type:
menu_provider,recreational,medical page
It does not accept per_page (results are fixed at 20 per page), q, skus, or the include_* options. It adds:
| Parameter | Type | Description |
|---|---|---|
uuids | string[] | Filter by specific Meta SKU UUIDs (repeated param) |
Response
The response uses the same pagination format. Each data entry is one listing: its retailer_id, its meta_sku, and a products array holding that listing. On /v2/products/meta, listings that share a meta_sku can span more than one page:
{
"data": [
{
"retailer_id": "10600",
"meta_sku": "a1b2c3d4-5678-90ab-cdef-1234567890ab",
"products": [
{
"product_name": "Blue Dream",
"brand_name": "Cookies",
"category": "Flower",
"latest_price": 45.00
}
]
}
],
"pagination": {
"total_records": 523,
"current_page": 1,
"total_pages": 27,
"next_page": 2,
"prev_page": null
}
}
Example Request
curl "https://api.cannmenus.com/v2/products/meta?states=California&category=Flower&page=1" \
-H "X-Token: YOUR_API_KEY"
The V1 meta endpoint (/v1/products/meta) accepts the same parameters, plus per_page (default 20, max 200), include_below_threshold, and include_all_menu_providers.
