Files
cannaiq/backend/docs/QUERY_API.md
Kelly daab0ae9b2 feat(api): Add payload query API and trusted origins management
Query API:
- GET /api/payloads/store/:id/query - Filter products with flexible params
  (brand, category, price_min/max, thc_min/max, search, sort, pagination)
- GET /api/payloads/store/:id/aggregate - Group by brand/category with metrics
  (count, avg_price, min_price, max_price, avg_thc, in_stock_count)
- Documentation at docs/QUERY_API.md

Trusted Origins Admin:
- GET/POST/PUT/DELETE /api/admin/trusted-origins - Manage auth bypass list
- Trusted IPs, domains, and regex patterns stored in DB
- 5-minute cache with invalidation on admin updates
- Fallback to hardcoded defaults if DB unavailable
- Migration 085 creates table with seed data

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-11 23:28:05 -07:00

7.3 KiB

CannaiQ Query API

Query raw crawl payload data with flexible filters, sorting, and aggregation.

Base URL

https://cannaiq.co/api/payloads

Authentication

Include your API key in the header:

X-API-Key: your-api-key

Endpoints

1. Query Products

Filter and search products from a store's latest crawl data.

GET /api/payloads/store/{dispensaryId}/query

Query Parameters

Parameter Type Description
brand string Filter by brand name (partial match)
category string Filter by category (flower, vape, edible, etc.)
subcategory string Filter by subcategory
strain_type string Filter by strain (indica, sativa, hybrid, cbd)
in_stock boolean Filter by stock status (true/false)
price_min number Minimum price
price_max number Maximum price
thc_min number Minimum THC percentage
thc_max number Maximum THC percentage
search string Search product name (partial match)
fields string Comma-separated fields to return
limit number Max results (default 100, max 1000)
offset number Skip results for pagination
sort string Sort by: name, price, thc, brand
order string Sort order: asc, desc

Available Fields

When using fields parameter, you can request:

  • id - Product ID
  • name - Product name
  • brand - Brand name
  • category - Product category
  • subcategory - Product subcategory
  • strain_type - Indica/Sativa/Hybrid/CBD
  • price - Current price
  • price_med - Medical price
  • price_rec - Recreational price
  • thc - THC percentage
  • cbd - CBD percentage
  • weight - Product weight/size
  • status - Stock status
  • in_stock - Boolean in-stock flag
  • image_url - Product image
  • description - Product description

Examples

Get all flower products under $40:

GET /api/payloads/store/112/query?category=flower&price_max=40

Search for "Blue Dream" with high THC:

GET /api/payloads/store/112/query?search=blue+dream&thc_min=20

Get only name and price for Alien Labs products:

GET /api/payloads/store/112/query?brand=Alien+Labs&fields=name,price,thc

Get top 10 highest THC products:

GET /api/payloads/store/112/query?sort=thc&order=desc&limit=10

Paginate through in-stock products:

GET /api/payloads/store/112/query?in_stock=true&limit=50&offset=0
GET /api/payloads/store/112/query?in_stock=true&limit=50&offset=50

Response

{
  "success": true,
  "dispensaryId": 112,
  "payloadId": 45,
  "fetchedAt": "2025-12-11T10:30:00Z",
  "query": {
    "filters": {
      "brand": "Alien Labs",
      "category": null,
      "price_max": null
    },
    "sort": "price",
    "order": "asc",
    "limit": 100,
    "offset": 0
  },
  "pagination": {
    "total": 15,
    "returned": 15,
    "limit": 100,
    "offset": 0,
    "has_more": false
  },
  "products": [
    {
      "id": "507f1f77bcf86cd799439011",
      "name": "Alien Labs - Baklava 3.5g",
      "brand": "Alien Labs",
      "category": "flower",
      "strain_type": "hybrid",
      "price": 55,
      "thc": "28.5",
      "in_stock": true
    }
  ]
}

2. Aggregate Data

Group products and calculate metrics.

GET /api/payloads/store/{dispensaryId}/aggregate

Query Parameters

Parameter Type Description
group_by string Required. Field to group by: brand, category, subcategory, strain_type
metrics string Comma-separated metrics (default: count)

Available Metrics

  • count - Number of products
  • avg_price - Average price
  • min_price - Lowest price
  • max_price - Highest price
  • avg_thc - Average THC percentage
  • in_stock_count - Number of in-stock products

Examples

Count products by brand:

GET /api/payloads/store/112/aggregate?group_by=brand

Get price stats by category:

GET /api/payloads/store/112/aggregate?group_by=category&metrics=count,avg_price,min_price,max_price

Get THC averages by strain type:

GET /api/payloads/store/112/aggregate?group_by=strain_type&metrics=count,avg_thc

Brand analysis with stock info:

GET /api/payloads/store/112/aggregate?group_by=brand&metrics=count,avg_price,in_stock_count

Response

{
  "success": true,
  "dispensaryId": 112,
  "payloadId": 45,
  "fetchedAt": "2025-12-11T10:30:00Z",
  "groupBy": "brand",
  "metrics": ["count", "avg_price"],
  "totalProducts": 450,
  "groupCount": 85,
  "aggregations": [
    {
      "brand": "Alien Labs",
      "count": 15,
      "avg_price": 52.33
    },
    {
      "brand": "Connected",
      "count": 12,
      "avg_price": 48.50
    }
  ]
}

3. Compare Stores (Price Comparison)

Query the same data from multiple stores and compare in your app:

// Get flower prices from Store A
const storeA = await fetch('/api/payloads/store/112/query?category=flower&fields=name,brand,price');

// Get flower prices from Store B
const storeB = await fetch('/api/payloads/store/115/query?category=flower&fields=name,brand,price');

// Compare in your app
const dataA = await storeA.json();
const dataB = await storeB.json();

// Find matching products and compare prices

4. Price History

For historical price data, use the snapshots endpoint:

GET /api/v1/products/{productId}/history?days=30

Or compare payloads over time:

GET /api/payloads/store/{dispensaryId}/diff?from={payloadId1}&to={payloadId2}

The diff endpoint shows:

  • Products added
  • Products removed
  • Price changes
  • Stock changes

5. List Stores

Get available dispensaries to query:

GET /api/stores

Returns all stores with their IDs, names, and locations.


Use Cases

Price Comparison App

// 1. Get stores in Arizona
const stores = await fetch('/api/stores?state=AZ').then(r => r.json());

// 2. Query flower prices from each store
const prices = await Promise.all(
  stores.map(store =>
    fetch(`/api/payloads/store/${store.id}/query?category=flower&fields=name,brand,price`)
      .then(r => r.json())
  )
);

// 3. Build comparison matrix in your app

Brand Analytics Dashboard

// Get brand presence across stores
const brandData = await Promise.all(
  storeIds.map(id =>
    fetch(`/api/payloads/store/${id}/aggregate?group_by=brand&metrics=count,avg_price`)
      .then(r => r.json())
  )
);

// Aggregate brand presence across all stores

Deal Finder

// Find high-THC flower under $30
const deals = await fetch(
  '/api/payloads/store/112/query?category=flower&price_max=30&thc_min=20&in_stock=true&sort=thc&order=desc'
).then(r => r.json());

Inventory Tracker

// Get products that went out of stock
const diff = await fetch('/api/payloads/store/112/diff').then(r => r.json());

const outOfStock = diff.details.stockChanges.filter(
  p => p.newStatus !== 'Active'
);

Rate Limits

  • Default: 100 requests/minute per API key
  • Contact support for higher limits

Error Responses

{
  "success": false,
  "error": "Error message here"
}

Common errors:

  • 404 - Store or payload not found
  • 400 - Missing required parameter
  • 401 - Invalid or missing API key
  • 429 - Rate limit exceeded