Product Search Guide

This guide walks through common workflows for finding cannabis products using the CannMenus API, with complete code examples.


Prerequisites

Install the requests library for Python examples:

pip install requests

Set up your API credentials:

import requests

API_URL = "https://api.cannmenus.com/v2"
RETAILERS_URL = "https://api.cannmenus.com/v1/retailers"  # retailers are V1-only
headers = {"X-Token": "YOUR_API_TOKEN"}

Search for products in a state:

response = requests.get(
    f"{API_URL}/products",
    headers=headers,
    params={
        "states": "California",
        "category": "Flower",
        "recreational": True,
        "page": 1
    }
)

data = response.json()
print(f"Found {data['pagination']['total_records']} products")

for sku_group in data["data"][:5]:
    product = sku_group["products"][0]
    print(f"- {product['brand_name']} {product['product_name']}: ${product['latest_price']}")

Find a Retailer and Get All Products

A common workflow: find a specific dispensary, then fetch all its products.

Step 1: Search for the Retailer

# Find a retailer by name in Illinois (use the full state name)
response = requests.get(
    RETAILERS_URL,
    headers=headers,
    params={
        "state": "Illinois",
        "name": "rise",
        "is_active": True,
        "page": 1
    }
)

retailers = response.json()["data"]
if not retailers:
    print("No retailers found")
    exit()

retailer = retailers[0]
print(f"Found: {retailer['dispensary_name']}")
print(f"Address: {retailer['physical_address']}, {retailer['city']}")
print(f"Retailer ID: {retailer['dispensary_id']}")

Step 2: Fetch All Products with Pagination

def get_all_products(retailer_id, state):
    """Fetch all products from a retailer, handling pagination."""
    all_products = []
    page = 1

    while True:
        response = requests.get(
            f"{API_URL}/products",
            headers=headers,
            params={
                "states": state,
                "retailers": retailer_id,
                "recreational": True,
                "page": page
            }
        )

        data = response.json()
        all_products.extend(data["data"])

        print(f"Page {page}/{data['pagination']['total_pages']}: fetched {len(data['data'])} SKUs")

        if data["pagination"]["next_page"] is None:
            break
        page += 1

    return all_products


# Get all products for the retailer
products = get_all_products(retailer["dispensary_id"], "Illinois")
print(f"\nTotal SKUs: {len(products)}")

Step 3: Analyze the Products

# Count products by category
from collections import Counter

categories = Counter()
for sku_group in products:
    for product in sku_group["products"]:
        categories[product["category"]] += 1

print("\nProducts by category:")
for category, count in categories.most_common():
    print(f"  {category}: {count}")

Complete Example: Retailer Product Export

Here's a complete script that finds a retailer and exports all products:

import requests
import json
from datetime import datetime

API_URL = "https://api.cannmenus.com/v2"
RETAILERS_URL = "https://api.cannmenus.com/v1/retailers"  # retailers are V1-only
headers = {"X-Token": "YOUR_API_TOKEN"}


def find_retailer(state, name):
    """Search for a retailer by full state name and retailer name."""
    response = requests.get(
        RETAILERS_URL,
        headers=headers,
        params={"state": state, "name": name, "is_active": True, "page": 1}
    )
    response.raise_for_status()

    retailers = response.json()["data"]
    if not retailers:
        raise ValueError(f"No retailer found matching '{name}' in {state}")

    return retailers[0]


def get_all_products(retailer_id, state):
    """Fetch all products from a retailer with pagination."""
    all_products = []
    page = 1

    while True:
        response = requests.get(
            f"{API_URL}/products",
            headers=headers,
            params={"states": state, "retailers": retailer_id, "recreational": True, "page": page}
        )
        response.raise_for_status()

        data = response.json()
        all_products.extend(data["data"])

        if data["pagination"]["next_page"] is None:
            break
        page += 1

    return all_products


def flatten_products(sku_groups):
    """Convert nested SKU groups to flat product list."""
    products = []
    for group in sku_groups:
        for product in group["products"]:
            products.append({
                "retailer_id": group["retailer_id"],
                "sku": group["sku"],
                **product
            })
    return products


def main():
    # Find the retailer
    retailer = find_retailer("Illinois", "rise")
    print(f"Found: {retailer['dispensary_name']}")
    print(f"Location: {retailer['city']}, {retailer['state']}")

    # Get all products
    print("\nFetching products...")
    sku_groups = get_all_products(retailer["dispensary_id"], "Illinois")
    products = flatten_products(sku_groups)

    print(f"Total products: {len(products)}")

    # Export to JSON
    output = {
        "retailer": retailer,
        "exported_at": datetime.now().isoformat(),
        "product_count": len(products),
        "products": products
    }

    filename = f"{retailer['dispensary_name'].replace(' ', '_')}_products.json"
    with open(filename, "w") as f:
        json.dump(output, f, indent=2)

    print(f"\nExported to {filename}")


if __name__ == "__main__":
    main()

Find products near a specific location:

# Search for edibles within 5 miles of downtown Denver
response = requests.get(
    f"{API_URL}/products",
    headers=headers,
    params={
        "states": "Colorado",
        "category": "Edible",
        "recreational": True,
        "lat": 39.7392,
        "lng": -104.9903,
        "distance": 5,
        "page": 1
    }
)

data = response.json()
print(f"Found {data['pagination']['total_records']} edibles near Denver")

Price Comparison Across Retailers

Compare prices for a specific product. A listing is on sale when original_price is present and greater than latest_price; latest_price does not include deals applied at checkout (see Deals & Promotions).

def compare_prices(state, brand, product_name):
    """Price range for a product across retailers, from the first page of results (up to 200 listings).

    product_name is a substring match, so results can include other variants (sizes, flavors);
    check display_weight / product_name before treating listings as the same product,
    and follow pagination.next_page for a full statewide view.
    """
    response = requests.get(
        f"{API_URL}/products",
        headers=headers,
        params={
            "states": state,
            "brand_name": brand,
            "product_name": product_name,
            "recreational": True,
            "page": 1,
            "per_page": 200
        }
    )

    data = response.json()
    prices = []

    for sku_group in data["data"]:
        for product in sku_group["products"]:
            if product["latest_price"] is None:
                continue
            original = product.get("original_price")
            prices.append({
                "retailer_id": sku_group["retailer_id"],
                "price": product["latest_price"],
                "original_price": original,
                "on_sale": original is not None and original > product["latest_price"],
                "menu_provider": product["menu_provider"]
            })

    if prices:
        prices.sort(key=lambda x: x["price"])
        print(f"\n{brand} {product_name} prices in {state}:")
        print(f"  Lowest: ${prices[0]['price']}")
        print(f"  Highest: ${prices[-1]['price']}")
        retailers = {p["retailer_id"] for p in prices}
        on_sale = {p["retailer_id"] for p in prices if p["on_sale"]}
        print(f"  {len(prices)} listings at {len(retailers)} retailers (first page)")
        print(f"  On sale at {len(on_sale)} retailers")

    return prices


compare_prices("California", "Stiiizy", "OG Kush")

Filter by Potency

Filter products by exact cannabinoid content:

# Flower with exactly 25.5% THC
response = requests.get(
    f"{API_URL}/products",
    headers=headers,
    params={
        "states": "Washington",
        "category": "Flower",
        "recreational": True,
        "percentage_thc": 25.5,
        "page": 1
    }
)

# Edibles with exactly 100mg THC
response = requests.get(
    f"{API_URL}/products",
    headers=headers,
    params={
        "states": "Colorado",
        "category": "Edible",
        "recreational": True,
        "mg_thc": 100,
        "page": 1
    }
)

Error Handling

Handle the status codes the REST API returns:

  • 403: no API token was sent
  • 404: invalid or expired token (or an unknown endpoint)
  • 422: invalid or missing parameters
  • 429: rate limited; wait for the number of seconds in the Retry-After header

See Errors for details.

def safe_api_call(url, params):
    """Make an API call with proper error handling."""
    try:
        response = requests.get(url, headers=headers, params=params, timeout=30)
    except requests.exceptions.RequestException as e:
        raise ConnectionError(f"Network error: {e}")

    if response.status_code == 200:
        return response.json()

    try:
        detail = response.json().get("detail")
    except ValueError:
        detail = response.text

    if response.status_code in (403, 404):
        raise PermissionError(f"Auth problem or unknown endpoint: {detail}")
    elif response.status_code == 422:
        raise ValueError(f"Invalid parameters: {detail}")
    elif response.status_code == 429:
        retry_after = response.headers.get("Retry-After")
        raise RuntimeError(f"Rate limited; retry after {retry_after}s: {detail}")
    else:
        raise Exception(f"API error {response.status_code}: {detail}")


# Usage
try:
    data = safe_api_call(f"{API_URL}/products", {"states": "California", "recreational": True, "page": 1})
    print(f"Found {data['pagination']['total_records']} products")
except Exception as e:
    print(f"Error: {e}")

Next Steps