- Fix column name from s.dutchie_plus_url to s.dutchie_url - Add availability tracking and product freshness APIs - Add crawl script for sequential dispensary processing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
56 KiB
Store Menu API Specification
Overview
This document specifies the Store-Facing API that powers the WordPress "Dutchie Plus replacement" plugin. This API is completely separate from the Brand Intelligence API.
Architecture Separation
┌─────────────────────────────────────────────────────────────────┐
│ CRAWLER + CANONICAL TABLES │
│ (Single Source of Truth) │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │
│ │ products │ │ stores │ │ store_products │ │
│ │ table │ │ table │ │ (current state) │ │
│ └──────────────┘ └──────────────┘ └──────────────────────┘ │
└─────────────────────────────┬───────────────────────────────────┘
│
┌─────────────────────┴─────────────────────┐
▼ ▼
┌───────────────────────────────┐ ┌───────────────────────────────┐
│ BRAND INTELLIGENCE API │ │ STORE MENU API │
│ (Cannabrands App) │ │ (WordPress Plugin) │
├───────────────────────────────┤ ├───────────────────────────────┤
│ Path: /api/brands/:key/... │ │ Path: /api/stores/:key/... │
│ Auth: JWT + brand_key claim │ │ Auth: X-Store-API-Key header │
│ Scope: Cross-store analytics │ │ Scope: Single store only │
│ │ │ │
│ READS FROM: │ │ READS FROM: │
│ • brand_daily_metrics │ │ • products │
│ • brand_promo_daily_metrics │ │ • categories │
│ • brand_store_events │ │ • stores │
│ • brand_store_presence │ │ • (NO analytics tables) │
│ • store_product_snapshots │ │ │
└───────────────────────────────┘ └───────────────────────────────┘
Key Principles
- Read-only - No writes to the database
- Store-scoped - Each request is scoped to a single store
- Current state only - No historical analytics
- Cache-friendly - Deterministic responses for given inputs
- WordPress-optimized - Designed for transient caching
1. Authentication
1.1 Store API Keys
Each store gets one or more API keys stored in a dedicated table:
CREATE TABLE store_api_keys (
id SERIAL PRIMARY KEY,
store_id INTEGER NOT NULL REFERENCES stores(id) ON DELETE CASCADE,
api_key VARCHAR(64) NOT NULL,
name VARCHAR(100) NOT NULL DEFAULT 'WordPress Plugin',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
rate_limit_hour INTEGER NOT NULL DEFAULT 1000,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ,
CONSTRAINT uq_store_api_keys_key UNIQUE (api_key)
);
CREATE INDEX idx_store_api_keys_key ON store_api_keys(api_key) WHERE is_active = TRUE;
CREATE INDEX idx_store_api_keys_store ON store_api_keys(store_id);
1.2 Request Authentication
Header: X-Store-API-Key: sk_live_abc123def456...
Resolution Flow:
Request: GET /api/stores/deeply-rooted/menu
Header: X-Store-API-Key: sk_live_abc123def456
↓
┌───────────────────────────────────────────────────┐
│ 1. Lookup API key in store_api_keys │
│ WHERE api_key = 'sk_live_abc123def456' │
│ AND is_active = TRUE │
│ AND (expires_at IS NULL OR expires_at > NOW())│
└───────────────────────────────────────────────────┘
↓
┌───────────────────────────────────────────────────┐
│ 2. Verify store_key matches the API key's store │
│ API key's store_id → stores.slug must match │
│ the :store_key in the URL path │
└───────────────────────────────────────────────────┘
↓
┌───────────────────────────────────────────────────┐
│ 3. Rate limit check (1000 req/hour default) │
└───────────────────────────────────────────────────┘
↓
Request proceeds
1.3 Store Key Resolution
The store_key path parameter can be:
- Store slug (e.g.,
deeply-rooted-sacramento) - Store ID (e.g.,
1)
async function resolveStoreKey(db: Pool, storeKey: string): Promise<number | null> {
// Try as numeric ID first
if (/^\d+$/.test(storeKey)) {
const byId = await db.query(
`SELECT id FROM stores WHERE id = $1`,
[parseInt(storeKey)]
);
if (byId.rows.length > 0) return byId.rows[0].id;
}
// Try as slug
const bySlug = await db.query(
`SELECT id FROM stores WHERE slug = $1`,
[storeKey]
);
if (bySlug.rows.length > 0) return bySlug.rows[0].id;
return null;
}
1.4 Security Guarantees
- API key only grants access to its associated store
- No cross-store data leakage possible
- Rate limiting prevents abuse
- Keys can be rotated/revoked without code changes
2. Canonical Data Types
2.1 ProductForStore Object
This is the canonical product representation used across all store endpoints:
interface ProductForStore {
// Identifiers
product_id: number; // products.id (canonical product)
store_product_id: number | null; // store_products.id (if using intelligence layer)
slug: string; // URL-safe identifier
// Basic info
name: string; // Product name (without brand prefix)
full_name: string; // Full name including brand
brand_name: string | null; // Brand name
brand_id: number | null; // canonical_brands.id (if mapped)
// Classification
category: CategoryInfo;
subcategory: CategoryInfo | null;
strain_type: 'indica' | 'sativa' | 'hybrid' | 'cbd' | null;
// Sizing
weight: string | null; // Display weight (e.g., "1g", "3.5g", "1oz")
weight_grams: number | null; // Numeric grams for sorting
// Cannabinoids
thc_percent: number | null;
thc_range: string | null; // "25-30%" if range
cbd_percent: number | null;
// Availability
in_stock: boolean;
// Pricing (all in cents for precision)
price_cents: number; // Current selling price
regular_price_cents: number | null; // Pre-discount price (if on special)
// Specials
is_on_special: boolean;
special: SpecialInfo | null;
// Media
image_url: string | null; // Primary image URL
images: ImageSet;
// Description
description: string | null;
// Metadata
terpenes: string[] | null;
effects: string[] | null;
flavors: string[] | null;
// Links
dutchie_url: string | null;
// Timestamps
last_updated_at: string; // ISO 8601
}
interface CategoryInfo {
id: number;
name: string;
slug: string;
}
interface SpecialInfo {
type: 'percent_off' | 'dollar_off' | 'bogo' | 'bundle' | 'set_price' | 'other';
text: string; // Raw special text for display
badge_text: string; // Short badge (e.g., "20% OFF", "BOGO")
value: number | null; // Discount value (percent or dollars)
savings_cents: number | null; // Calculated savings in cents
}
interface ImageSet {
thumbnail: string | null;
medium: string | null;
full: string | null;
}
2.2 Example ProductForStore JSON
{
"product_id": 12345,
"store_product_id": 67890,
"slug": "thunder-bud-1g-pre-roll",
"name": "1g Pre-Roll",
"full_name": "Thunder Bud 1g Pre-Roll",
"brand_name": "Thunder Bud",
"brand_id": 22,
"category": {
"id": 3,
"name": "Pre-Rolls",
"slug": "pre-rolls"
},
"subcategory": {
"id": 15,
"name": "Singles",
"slug": "singles"
},
"strain_type": "hybrid",
"weight": "1g",
"weight_grams": 1.0,
"thc_percent": 27.5,
"thc_range": null,
"cbd_percent": 0.1,
"in_stock": true,
"price_cents": 1200,
"regular_price_cents": 1500,
"is_on_special": true,
"special": {
"type": "percent_off",
"text": "20% off all Thunder Bud pre-rolls",
"badge_text": "20% OFF",
"value": 20,
"savings_cents": 300
},
"image_url": "https://images.dutchie.com/abc123/medium.jpg",
"images": {
"thumbnail": "https://images.dutchie.com/abc123/thumb.jpg",
"medium": "https://images.dutchie.com/abc123/medium.jpg",
"full": "https://images.dutchie.com/abc123/full.jpg"
},
"description": "Premium pre-roll featuring Alien Marker strain. Hand-rolled with care.",
"terpenes": ["Limonene", "Myrcene"],
"effects": ["Relaxed", "Happy"],
"flavors": ["Citrus", "Earthy"],
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll",
"last_updated_at": "2025-02-28T15:03:00Z"
}
2.3 CategoryForStore Object
interface CategoryForStore {
id: number;
name: string;
slug: string;
icon: string | null; // Icon identifier
product_count: number; // Total products in category
in_stock_count: number; // Products currently in stock
on_special_count: number; // Products on special
children: CategoryForStore[]; // Subcategories
}
3. API Endpoints
3.1 GET /api/stores/:store_key/menu
Returns the full product menu for a store.
Path Parameters:
| Param | Type | Description |
|---|---|---|
store_key |
string | Store slug or ID |
Query Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string | No | - | Filter by category slug (e.g., flower, pre-rolls) |
brand |
string | No | - | Filter by brand name (partial match) |
search |
string | No | - | Free text search on product name |
in_stock |
boolean | No | true |
Filter by stock status |
sort |
string | No | name_asc |
Sort order (see below) |
page |
number | No | 1 |
Page number (1-indexed) |
per_page |
number | No | 50 |
Products per page (max 200) |
Sort Options:
name_asc- Name A-Z (default)name_desc- Name Z-Aprice_asc- Price low to highprice_desc- Price high to lowthc_desc- THC% high to lownewest- Most recently updated first
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento",
"city": "Sacramento",
"state": "CA",
"logo_url": null,
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted"
},
"meta": {
"total_products": 342,
"total_pages": 7,
"current_page": 1,
"per_page": 50,
"filters_applied": {
"category": null,
"brand": null,
"search": null,
"in_stock": true
},
"sort": "name_asc",
"generated_at": "2025-02-28T15:00:00Z",
"cache_ttl_seconds": 300
},
"products": [
{
"product_id": 12345,
"store_product_id": 67890,
"slug": "thunder-bud-1g-pre-roll",
"name": "1g Pre-Roll",
"full_name": "Thunder Bud 1g Pre-Roll",
"brand_name": "Thunder Bud",
"brand_id": 22,
"category": {
"id": 3,
"name": "Pre-Rolls",
"slug": "pre-rolls"
},
"subcategory": null,
"strain_type": "hybrid",
"weight": "1g",
"weight_grams": 1.0,
"thc_percent": 27.5,
"thc_range": null,
"cbd_percent": 0.1,
"in_stock": true,
"price_cents": 1200,
"regular_price_cents": 1500,
"is_on_special": true,
"special": {
"type": "percent_off",
"text": "20% off all Thunder Bud pre-rolls",
"badge_text": "20% OFF",
"value": 20,
"savings_cents": 300
},
"image_url": "https://images.dutchie.com/abc123/medium.jpg",
"images": {
"thumbnail": "https://images.dutchie.com/abc123/thumb.jpg",
"medium": "https://images.dutchie.com/abc123/medium.jpg",
"full": "https://images.dutchie.com/abc123/full.jpg"
},
"description": "Premium pre-roll featuring Alien Marker strain.",
"terpenes": ["Limonene", "Myrcene"],
"effects": ["Relaxed", "Happy"],
"flavors": ["Citrus", "Earthy"],
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll",
"last_updated_at": "2025-02-28T15:03:00Z"
}
// ... more products
]
}
SQL Query:
-- Main menu query
SELECT
p.id AS product_id,
p.slug,
p.name,
COALESCE(p.brand || ' ' || p.name, p.name) AS full_name,
p.brand AS brand_name,
cb.id AS brand_id,
p.category_id,
c.name AS category_name,
c.slug AS category_slug,
pc.id AS parent_category_id,
pc.name AS parent_category_name,
pc.slug AS parent_category_slug,
p.strain_type,
p.weight,
-- Parse weight to grams for sorting
CASE
WHEN p.weight ~ '^\d+(\.\d+)?g$' THEN CAST(REGEXP_REPLACE(p.weight, 'g$', '') AS NUMERIC)
WHEN p.weight ~ '^\d+(\.\d+)?oz$' THEN CAST(REGEXP_REPLACE(p.weight, 'oz$', '') AS NUMERIC) * 28.35
ELSE NULL
END AS weight_grams,
p.thc_percentage AS thc_percent,
p.cbd_percentage AS cbd_percent,
p.in_stock,
-- Current price in cents
ROUND(COALESCE(p.price, 0) * 100)::INTEGER AS price_cents,
-- Regular price (if on special)
CASE
WHEN p.original_price IS NOT NULL AND p.original_price > p.price
THEN ROUND(p.original_price * 100)::INTEGER
ELSE NULL
END AS regular_price_cents,
-- Special detection
(p.special_text IS NOT NULL AND p.special_text != '') AS is_on_special,
p.special_text,
-- Images
p.image_url_full,
p.image_url,
p.thumbnail_path,
p.medium_path,
-- Description & metadata
p.description,
p.metadata,
p.dutchie_url,
p.last_seen_at AS last_updated_at
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN categories pc ON pc.id = c.parent_id
LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(p.brand)
WHERE p.store_id = $1
AND ($2::TEXT IS NULL OR c.slug = $2 OR pc.slug = $2) -- category filter
AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%') -- brand filter
AND ($4::TEXT IS NULL OR p.name ILIKE '%' || $4 || '%') -- search filter
AND ($5::BOOLEAN IS NULL OR p.in_stock = $5) -- in_stock filter
ORDER BY
CASE WHEN $6 = 'name_asc' THEN p.name END ASC,
CASE WHEN $6 = 'name_desc' THEN p.name END DESC,
CASE WHEN $6 = 'price_asc' THEN p.price END ASC,
CASE WHEN $6 = 'price_desc' THEN p.price END DESC,
CASE WHEN $6 = 'thc_desc' THEN p.thc_percentage END DESC NULLS LAST,
CASE WHEN $6 = 'newest' THEN p.last_seen_at END DESC,
p.name ASC -- Secondary sort for stability
LIMIT $7 OFFSET $8;
-- Count query for pagination
SELECT COUNT(*) AS total
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN categories pc ON pc.id = c.parent_id
WHERE p.store_id = $1
AND ($2::TEXT IS NULL OR c.slug = $2 OR pc.slug = $2)
AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%')
AND ($4::TEXT IS NULL OR p.name ILIKE '%' || $4 || '%')
AND ($5::BOOLEAN IS NULL OR p.in_stock = $5);
3.2 GET /api/stores/:store_key/specials
Returns only products currently on special.
Query Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string | No | - | Filter by category slug |
brand |
string | No | - | Filter by brand name |
special_type |
string | No | - | Filter: percent_off, dollar_off, bogo, bundle |
sort |
string | No | savings_desc |
Sort order |
limit |
number | No | 50 |
Max products (max 100) |
Sort Options:
savings_desc- Highest savings first (default)savings_percent_desc- Highest % discount firstprice_asc- Price low to highname_asc- Name A-Z
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"meta": {
"total_specials": 34,
"filters_applied": {
"category": null,
"brand": null,
"special_type": null
},
"limit": 50,
"generated_at": "2025-02-28T15:00:00Z",
"cache_ttl_seconds": 300
},
"specials": [
{
"product_id": 12345,
"store_product_id": 67890,
"slug": "thunder-bud-1g-pre-roll",
"name": "1g Pre-Roll",
"full_name": "Thunder Bud 1g Pre-Roll",
"brand_name": "Thunder Bud",
"brand_id": 22,
"category": {
"id": 3,
"name": "Pre-Rolls",
"slug": "pre-rolls"
},
"subcategory": null,
"strain_type": "hybrid",
"weight": "1g",
"weight_grams": 1.0,
"thc_percent": 27.5,
"thc_range": null,
"cbd_percent": 0.1,
"in_stock": true,
"price_cents": 1200,
"regular_price_cents": 1500,
"is_on_special": true,
"special": {
"type": "percent_off",
"text": "20% off all Thunder Bud pre-rolls",
"badge_text": "20% OFF",
"value": 20,
"savings_cents": 300
},
"image_url": "https://images.dutchie.com/abc123/medium.jpg",
"images": {
"thumbnail": "https://images.dutchie.com/abc123/thumb.jpg",
"medium": "https://images.dutchie.com/abc123/medium.jpg",
"full": "https://images.dutchie.com/abc123/full.jpg"
},
"description": "Premium pre-roll featuring Alien Marker strain.",
"terpenes": null,
"effects": null,
"flavors": null,
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll",
"last_updated_at": "2025-02-28T15:03:00Z"
}
// ... more specials
],
"summary": {
"by_type": {
"percent_off": 18,
"dollar_off": 5,
"bogo": 8,
"bundle": 2,
"other": 1
},
"by_category": [
{ "slug": "flower", "name": "Flower", "count": 12 },
{ "slug": "concentrates", "name": "Concentrates", "count": 10 },
{ "slug": "pre-rolls", "name": "Pre-Rolls", "count": 8 }
],
"avg_savings_percent": 22.5,
"total_savings_available_cents": 15600
}
}
SQL Query:
SELECT
p.id AS product_id,
p.slug,
p.name,
COALESCE(p.brand || ' ' || p.name, p.name) AS full_name,
p.brand AS brand_name,
cb.id AS brand_id,
c.id AS category_id,
c.name AS category_name,
c.slug AS category_slug,
p.strain_type,
p.weight,
p.thc_percentage AS thc_percent,
p.cbd_percentage AS cbd_percent,
p.in_stock,
ROUND(COALESCE(p.price, 0) * 100)::INTEGER AS price_cents,
ROUND(COALESCE(p.original_price, p.price) * 100)::INTEGER AS regular_price_cents,
TRUE AS is_on_special,
p.special_text,
p.image_url_full,
p.image_url,
p.thumbnail_path,
p.medium_path,
p.description,
p.metadata,
p.dutchie_url,
p.last_seen_at AS last_updated_at,
-- Calculated savings
ROUND((COALESCE(p.original_price, p.price) - p.price) * 100)::INTEGER AS savings_cents,
CASE
WHEN p.original_price > 0
THEN ROUND(((p.original_price - p.price) / p.original_price) * 100, 1)
ELSE 0
END AS savings_percent
FROM products p
LEFT JOIN categories c ON c.id = p.category_id
LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(p.brand)
WHERE p.store_id = $1
AND p.special_text IS NOT NULL
AND p.special_text != ''
AND p.in_stock = TRUE -- Only show in-stock specials
AND ($2::TEXT IS NULL OR c.slug = $2)
AND ($3::TEXT IS NULL OR p.brand ILIKE '%' || $3 || '%')
ORDER BY
CASE WHEN $4 = 'savings_desc' THEN (COALESCE(p.original_price, p.price) - p.price) END DESC,
CASE WHEN $4 = 'savings_percent_desc' THEN
CASE WHEN p.original_price > 0 THEN ((p.original_price - p.price) / p.original_price) ELSE 0 END
END DESC,
CASE WHEN $4 = 'price_asc' THEN p.price END ASC,
CASE WHEN $4 = 'name_asc' THEN p.name END ASC,
p.name ASC
LIMIT $5;
3.3 GET /api/stores/:store_key/products
General paginated product listing with full filter support. Used for search results and custom layouts.
Query Parameters:
| Param | Type | Required | Default | Description |
|---|---|---|---|---|
category |
string | No | - | Filter by category slug |
brand |
string | No | - | Filter by brand name |
search |
string | No | - | Free text search |
in_stock |
boolean | No | - | Filter by stock status (null = all) |
specials_only |
boolean | No | false |
Only show products on special |
strain_type |
string | No | - | Filter: indica, sativa, hybrid, cbd |
min_thc |
number | No | - | Minimum THC% |
max_price |
number | No | - | Maximum price in dollars |
sort |
string | No | name_asc |
Sort order |
page |
number | No | 1 |
Page number |
per_page |
number | No | 24 |
Products per page (max 100) |
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"meta": {
"total_products": 45,
"total_pages": 2,
"current_page": 1,
"per_page": 24,
"filters_applied": {
"category": "flower",
"brand": null,
"search": null,
"in_stock": true,
"specials_only": false,
"strain_type": null,
"min_thc": null,
"max_price": null
},
"sort": "thc_desc",
"generated_at": "2025-02-28T15:00:00Z",
"cache_ttl_seconds": 300
},
"products": [
// ... array of ProductForStore objects
],
"facets": {
"categories": [
{ "slug": "indoor", "name": "Indoor", "count": 25 },
{ "slug": "outdoor", "name": "Outdoor", "count": 15 },
{ "slug": "greenhouse", "name": "Greenhouse", "count": 5 }
],
"brands": [
{ "name": "Raw Garden", "count": 12 },
{ "name": "Connected", "count": 8 },
{ "name": "Alien Labs", "count": 5 }
],
"strain_types": [
{ "type": "hybrid", "count": 20 },
{ "type": "indica", "count": 15 },
{ "type": "sativa", "count": 10 }
],
"price_range": {
"min_cents": 1500,
"max_cents": 8500
},
"thc_range": {
"min": 15.0,
"max": 35.0
}
}
}
3.4 GET /api/stores/:store_key/categories
Returns the category tree with product counts.
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"meta": {
"total_categories": 12,
"generated_at": "2025-02-28T15:00:00Z",
"cache_ttl_seconds": 600
},
"categories": [
{
"id": 1,
"name": "Flower",
"slug": "flower",
"icon": "flower",
"product_count": 85,
"in_stock_count": 72,
"on_special_count": 8,
"children": [
{
"id": 10,
"name": "Indoor",
"slug": "indoor",
"icon": null,
"product_count": 45,
"in_stock_count": 40,
"on_special_count": 4,
"children": []
},
{
"id": 11,
"name": "Outdoor",
"slug": "outdoor",
"icon": null,
"product_count": 25,
"in_stock_count": 20,
"on_special_count": 2,
"children": []
},
{
"id": 12,
"name": "Greenhouse",
"slug": "greenhouse",
"icon": null,
"product_count": 15,
"in_stock_count": 12,
"on_special_count": 2,
"children": []
}
]
},
{
"id": 2,
"name": "Pre-Rolls",
"slug": "pre-rolls",
"icon": "joint",
"product_count": 42,
"in_stock_count": 38,
"on_special_count": 5,
"children": [
{
"id": 20,
"name": "Singles",
"slug": "singles",
"icon": null,
"product_count": 28,
"in_stock_count": 25,
"on_special_count": 3,
"children": []
},
{
"id": 21,
"name": "Packs",
"slug": "packs",
"icon": null,
"product_count": 14,
"in_stock_count": 13,
"on_special_count": 2,
"children": []
}
]
},
{
"id": 3,
"name": "Vapes",
"slug": "vapes",
"icon": "vape",
"product_count": 65,
"in_stock_count": 58,
"on_special_count": 7,
"children": []
},
{
"id": 4,
"name": "Concentrates",
"slug": "concentrates",
"icon": "dab",
"product_count": 48,
"in_stock_count": 42,
"on_special_count": 6,
"children": []
},
{
"id": 5,
"name": "Edibles",
"slug": "edibles",
"icon": "cookie",
"product_count": 56,
"in_stock_count": 50,
"on_special_count": 4,
"children": []
},
{
"id": 6,
"name": "Tinctures",
"slug": "tinctures",
"icon": "dropper",
"product_count": 18,
"in_stock_count": 16,
"on_special_count": 2,
"children": []
},
{
"id": 7,
"name": "Topicals",
"slug": "topicals",
"icon": "lotion",
"product_count": 12,
"in_stock_count": 10,
"on_special_count": 1,
"children": []
},
{
"id": 8,
"name": "Accessories",
"slug": "accessories",
"icon": "gear",
"product_count": 16,
"in_stock_count": 14,
"on_special_count": 1,
"children": []
}
]
}
SQL Query:
-- Get categories with counts for a store
WITH category_counts AS (
SELECT
c.id,
c.name,
c.slug,
c.parent_id,
c.icon,
COUNT(p.id) AS product_count,
COUNT(p.id) FILTER (WHERE p.in_stock = TRUE) AS in_stock_count,
COUNT(p.id) FILTER (WHERE p.special_text IS NOT NULL AND p.special_text != '') AS on_special_count
FROM categories c
LEFT JOIN products p ON p.category_id = c.id AND p.store_id = $1
WHERE c.store_id = $1 OR c.store_id IS NULL -- Global + store-specific categories
GROUP BY c.id, c.name, c.slug, c.parent_id, c.icon
HAVING COUNT(p.id) > 0 -- Only categories with products
)
SELECT * FROM category_counts
ORDER BY
CASE WHEN parent_id IS NULL THEN 0 ELSE 1 END, -- Parents first
product_count DESC,
name ASC;
3.5 GET /api/stores/:store_key/brands
Returns all brands available at the store.
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"meta": {
"total_brands": 48,
"generated_at": "2025-02-28T15:00:00Z",
"cache_ttl_seconds": 600
},
"brands": [
{
"name": "Raw Garden",
"slug": "raw-garden",
"brand_id": 15,
"product_count": 24,
"in_stock_count": 21,
"on_special_count": 3,
"categories": ["Concentrates", "Vapes"],
"price_range": {
"min_cents": 2500,
"max_cents": 6500
}
},
{
"name": "Stiiizy",
"slug": "stiiizy",
"brand_id": 8,
"product_count": 18,
"in_stock_count": 15,
"on_special_count": 0,
"categories": ["Vapes", "Flower", "Edibles"],
"price_range": {
"min_cents": 2000,
"max_cents": 5500
}
},
{
"name": "Thunder Bud",
"slug": "thunder-bud",
"brand_id": 22,
"product_count": 12,
"in_stock_count": 10,
"on_special_count": 4,
"categories": ["Flower", "Pre-Rolls"],
"price_range": {
"min_cents": 1200,
"max_cents": 4500
}
}
// ... more brands
]
}
3.6 GET /api/stores/:store_key/product/:product_slug
Returns detailed information for a single product.
Response:
{
"store": {
"id": 1,
"name": "Deeply Rooted",
"slug": "deeply-rooted-sacramento"
},
"product": {
"product_id": 12345,
"store_product_id": 67890,
"slug": "thunder-bud-1g-pre-roll",
"name": "1g Pre-Roll",
"full_name": "Thunder Bud 1g Pre-Roll",
"brand_name": "Thunder Bud",
"brand_id": 22,
"category": {
"id": 3,
"name": "Pre-Rolls",
"slug": "pre-rolls"
},
"subcategory": {
"id": 15,
"name": "Singles",
"slug": "singles"
},
"strain_type": "hybrid",
"strain_name": "Alien Marker",
"weight": "1g",
"weight_grams": 1.0,
"thc_percent": 27.5,
"thc_range": null,
"cbd_percent": 0.1,
"in_stock": true,
"price_cents": 1200,
"regular_price_cents": 1500,
"is_on_special": true,
"special": {
"type": "percent_off",
"text": "20% off all Thunder Bud pre-rolls",
"badge_text": "20% OFF",
"value": 20,
"savings_cents": 300
},
"image_url": "https://images.dutchie.com/abc123/medium.jpg",
"images": {
"thumbnail": "https://images.dutchie.com/abc123/thumb.jpg",
"medium": "https://images.dutchie.com/abc123/medium.jpg",
"full": "https://images.dutchie.com/abc123/full.jpg"
},
"description": "Premium pre-roll featuring Alien Marker strain. Hand-rolled with care using top-shelf flower. Perfect for a smooth, balanced experience.",
"terpenes": [
{ "name": "Limonene", "percentage": 1.2 },
{ "name": "Myrcene", "percentage": 0.8 },
{ "name": "Caryophyllene", "percentage": 0.5 }
],
"effects": ["Relaxed", "Happy", "Euphoric", "Uplifted"],
"flavors": ["Citrus", "Earthy", "Pine"],
"lineage": "Unknown lineage",
"dutchie_url": "https://dutchie.com/dispensary/deeply-rooted/product/thunder-bud-1g-pre-roll",
"last_updated_at": "2025-02-28T15:03:00Z"
},
"related_products": [
{
"product_id": 12346,
"slug": "thunder-bud-3-5g-flower",
"name": "3.5g Flower",
"full_name": "Thunder Bud 3.5g Flower",
"brand_name": "Thunder Bud",
"category": { "id": 1, "name": "Flower", "slug": "flower" },
"price_cents": 3500,
"image_url": "https://images.dutchie.com/def456/medium.jpg",
"in_stock": true,
"is_on_special": true
},
{
"product_id": 12400,
"slug": "alien-labs-pre-roll-1g",
"name": "1g Pre-Roll",
"full_name": "Alien Labs 1g Pre-Roll",
"brand_name": "Alien Labs",
"category": { "id": 3, "name": "Pre-Rolls", "slug": "pre-rolls" },
"price_cents": 1800,
"image_url": "https://images.dutchie.com/ghi789/medium.jpg",
"in_stock": true,
"is_on_special": false
}
]
}
4. Specials Detection (API Layer)
Important
: The crawler is FROZEN. All specials detection happens in the API layer, not in the crawler. See CRAWL_OPERATIONS.md for the frozen crawler policy.
4.1 Why API-Layer Detection?
The crawler captures special information but doesn't always set the is_special flag correctly:
| Data Source | Current State | Products |
|---|---|---|
is_special = true |
Always false | 0 |
| "Special Offer" in product name | Embedded in name text | 325 |
sale_price < regular_price |
Price comparison | 4 |
special_text field |
Empty | 0 |
Since the crawler is frozen, we detect specials at query time using multiple signals.
4.2 Specials Detection Rules (Priority Order)
The API determines is_on_special = true if ANY of these conditions are met:
-- Computed is_on_special in API queries
CASE
-- Rule 1: "Special Offer" appears in the product name
WHEN p.name ILIKE '%Special Offer%' THEN TRUE
-- Rule 2: sale_price is less than regular_price
WHEN p.sale_price IS NOT NULL
AND p.regular_price IS NOT NULL
AND p.sale_price::numeric < p.regular_price::numeric THEN TRUE
-- Rule 3: price is less than original_price
WHEN p.price IS NOT NULL
AND p.original_price IS NOT NULL
AND p.price::numeric < p.original_price::numeric THEN TRUE
-- Rule 4: special_text field is populated (if crawler sets it)
WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN TRUE
ELSE FALSE
END AS is_on_special
4.3 Clean Product Name Function
Strip "Special Offer" from the display name to avoid redundancy:
function cleanProductName(rawName: string): string {
return rawName
.replace(/\s*Special Offer\s*$/i, '') // Remove trailing "Special Offer"
.replace(/\s+$/, '') // Trim trailing whitespace
.trim();
}
// Example:
// Input: "Canamo Cured Batter | CuracaoCanamo ConcentratesHybridTHC: 77.66%CBD: 0.15%Special Offer"
// Output: "Canamo Cured Batter | CuracaoCanamo ConcentratesHybridTHC: 77.66%CBD: 0.15%"
4.4 Compute Discount Percentage
function computeDiscountPercent(
salePriceCents: number | null,
regularPriceCents: number | null
): number | null {
if (!salePriceCents || !regularPriceCents || regularPriceCents <= 0) {
return null;
}
if (salePriceCents >= regularPriceCents) {
return null; // No discount
}
return Math.round((1 - salePriceCents / regularPriceCents) * 100);
}
4.5 Updated Specials SQL Query
Replace the specials query in Section 3.2 with this API-layer detection query:
-- Get specials with API-layer detection
WITH detected_specials AS (
SELECT
p.*,
-- Detect special using multiple rules
CASE
WHEN p.name ILIKE '%Special Offer%' THEN TRUE
WHEN p.sale_price IS NOT NULL
AND p.regular_price IS NOT NULL
AND p.sale_price::numeric < p.regular_price::numeric THEN TRUE
WHEN p.price IS NOT NULL
AND p.original_price IS NOT NULL
AND p.price::numeric < p.original_price::numeric THEN TRUE
WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN TRUE
ELSE FALSE
END AS is_on_special,
-- Determine special type
CASE
WHEN p.name ILIKE '%Special Offer%' THEN 'special_offer'
WHEN p.sale_price IS NOT NULL
AND p.regular_price IS NOT NULL
AND p.sale_price::numeric < p.regular_price::numeric THEN 'percent_off'
WHEN p.price IS NOT NULL
AND p.original_price IS NOT NULL
AND p.price::numeric < p.original_price::numeric THEN 'percent_off'
WHEN p.special_text IS NOT NULL AND p.special_text != '' THEN 'promo'
ELSE NULL
END AS computed_special_type,
-- Compute discount percentage
CASE
WHEN p.sale_price IS NOT NULL
AND p.regular_price IS NOT NULL
AND p.regular_price::numeric > 0
THEN ROUND((1 - p.sale_price::numeric / p.regular_price::numeric) * 100)
WHEN p.price IS NOT NULL
AND p.original_price IS NOT NULL
AND p.original_price::numeric > 0
THEN ROUND((1 - p.price::numeric / p.original_price::numeric) * 100)
ELSE NULL
END AS discount_percent,
-- Clean product name (remove "Special Offer" suffix)
REGEXP_REPLACE(p.name, '\s*Special Offer\s*$', '', 'i') AS clean_name
FROM products p
WHERE p.store_id = $1
AND p.in_stock = TRUE
)
SELECT
ds.id AS product_id,
ds.slug,
ds.clean_name AS name,
COALESCE(ds.brand || ' ' || ds.clean_name, ds.clean_name) AS full_name,
ds.brand AS brand_name,
cb.id AS brand_id,
c.id AS category_id,
c.name AS category_name,
c.slug AS category_slug,
ds.strain_type,
ds.weight,
ds.thc_percentage AS thc_percent,
ds.cbd_percentage AS cbd_percent,
ds.in_stock,
-- Price in cents
ROUND(COALESCE(ds.sale_price, ds.price, 0) * 100)::INTEGER AS price_cents,
-- Regular price in cents
ROUND(COALESCE(ds.regular_price, ds.original_price, ds.price) * 100)::INTEGER AS regular_price_cents,
ds.is_on_special,
ds.computed_special_type,
ds.discount_percent,
ds.image_url_full,
ds.image_url,
ds.thumbnail_path,
ds.medium_path,
ds.description,
ds.metadata,
ds.dutchie_url,
ds.last_seen_at AS last_updated_at,
-- Calculated savings
CASE
WHEN ds.regular_price IS NOT NULL AND ds.sale_price IS NOT NULL
THEN ROUND((ds.regular_price - ds.sale_price) * 100)::INTEGER
WHEN ds.original_price IS NOT NULL AND ds.price IS NOT NULL
THEN ROUND((ds.original_price - ds.price) * 100)::INTEGER
ELSE NULL
END AS savings_cents
FROM detected_specials ds
LEFT JOIN categories c ON c.id = ds.category_id
LEFT JOIN canonical_brands cb ON LOWER(cb.name) = LOWER(ds.brand)
WHERE ds.is_on_special = TRUE
AND ($2::TEXT IS NULL OR c.slug = $2) -- category filter
AND ($3::TEXT IS NULL OR ds.brand ILIKE '%' || $3 || '%') -- brand filter
ORDER BY
ds.discount_percent DESC NULLS LAST,
ds.name ASC
LIMIT $4 OFFSET $5;
4.6 Special Badge Generation
Generate the badge text based on detected special type:
function generateSpecialBadge(
specialType: string | null,
discountPercent: number | null,
hasSpecialOfferInName: boolean
): string {
if (discountPercent && discountPercent > 0) {
return `${discountPercent}% OFF`;
}
if (hasSpecialOfferInName) {
return 'SPECIAL';
}
switch (specialType) {
case 'percent_off':
return discountPercent ? `${discountPercent}% OFF` : 'SALE';
case 'special_offer':
return 'SPECIAL';
case 'promo':
return 'PROMO';
default:
return 'DEAL';
}
}
5. Special Text Parsing
5.1 Parse Function
The special parsing logic determines special.type, special.badge_text, and special.value:
interface ParsedSpecial {
type: 'percent_off' | 'dollar_off' | 'bogo' | 'bundle' | 'set_price' | 'other';
text: string; // Raw text
badge_text: string; // Short display text
value: number | null; // Numeric value
savings_cents: number | null; // Calculated savings
}
function parseSpecialText(
specialText: string | null,
currentPriceCents: number,
regularPriceCents: number | null
): ParsedSpecial | null {
if (!specialText || specialText.trim() === '') {
return null;
}
const text = specialText.trim();
const lower = text.toLowerCase();
// Calculate savings from price difference
const savingsCents = regularPriceCents && regularPriceCents > currentPriceCents
? regularPriceCents - currentPriceCents
: null;
// 1. Percent off: "20% off", "25% OFF", "Save 30%"
const pctMatch = lower.match(/(\d+(?:\.\d+)?)\s*%\s*(?:off|discount)?/i)
|| lower.match(/save\s*(\d+(?:\.\d+)?)\s*%/i);
if (pctMatch) {
const percent = parseFloat(pctMatch[1]);
return {
type: 'percent_off',
text,
badge_text: `${Math.round(percent)}% OFF`,
value: percent,
savings_cents: savingsCents
};
}
// 2. Dollar off: "$5 off", "$10 OFF"
const dollarMatch = lower.match(/\$(\d+(?:\.\d+)?)\s*off/i);
if (dollarMatch) {
const dollars = parseFloat(dollarMatch[1]);
return {
type: 'dollar_off',
text,
badge_text: `$${Math.round(dollars)} OFF`,
value: dollars,
savings_cents: dollars * 100
};
}
// 3. BOGO: "BOGO", "Buy One Get One", "B1G1", "Buy 2 Get 1"
if (/\bbogo\b|buy\s*one\s*get\s*one|b1g1/i.test(lower)) {
return {
type: 'bogo',
text,
badge_text: 'BOGO',
value: 50, // 50% effective discount
savings_cents: savingsCents
};
}
const bogoMatch = lower.match(/buy\s*(\d+)\s*get\s*(\d+)/i);
if (bogoMatch) {
const buy = parseInt(bogoMatch[1]);
const get = parseInt(bogoMatch[2]);
return {
type: 'bogo',
text,
badge_text: `B${buy}G${get}`,
value: Math.round((get / (buy + get)) * 100),
savings_cents: savingsCents
};
}
// 4. Bundle: "3 for $100", "2/$50"
const bundleMatch = lower.match(/(\d+)\s*(?:for|\/)\s*\$(\d+(?:\.\d+)?)/i);
if (bundleMatch) {
const qty = parseInt(bundleMatch[1]);
const price = parseFloat(bundleMatch[2]);
return {
type: 'bundle',
text,
badge_text: `${qty} FOR $${Math.round(price)}`,
value: price,
savings_cents: null // Can't calculate without knowing single item price
};
}
// 5. Set price: "Now $25", "Only $30"
const setPriceMatch = lower.match(/(?:now|only|just|sale)\s*\$(\d+(?:\.\d+)?)/i);
if (setPriceMatch) {
const setPrice = parseFloat(setPriceMatch[1]);
return {
type: 'set_price',
text,
badge_text: `NOW $${Math.round(setPrice)}`,
value: setPrice,
savings_cents: savingsCents
};
}
// 6. Other - has text but couldn't parse
return {
type: 'other',
text,
badge_text: 'SPECIAL',
value: null,
savings_cents: savingsCents
};
}
5. Caching Strategy
5.1 Server-Side Cache Headers
All store API responses include cache control headers:
HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300, stale-while-revalidate=60
ETag: "abc123def456"
X-Cache-TTL: 300
X-Generated-At: 2025-02-28T15:00:00Z
5.2 Recommended TTLs
| Endpoint | TTL | Rationale |
|---|---|---|
/menu |
5 minutes | Prices and stock change frequently |
/specials |
5 minutes | Deals may be time-sensitive |
/products |
5 minutes | Same as menu |
/categories |
10 minutes | Category structure rarely changes |
/brands |
10 minutes | Brand list rarely changes |
/product/:slug |
5 minutes | Individual product details |
5.3 WordPress Transient Strategy
Recommended caching approach for the WP plugin:
class Cannabrands_Cache {
const PREFIX = 'cbm_';
// Default TTLs in seconds
const TTL_MENU = 300; // 5 minutes
const TTL_SPECIALS = 300; // 5 minutes
const TTL_CATEGORIES = 600; // 10 minutes
const TTL_BRANDS = 600; // 10 minutes
/**
* Build a deterministic cache key
*/
public static function build_key(string $endpoint, array $params = []): string {
$store_key = get_option('cannabrands_store_key');
// Sort params for consistent key
ksort($params);
$param_hash = md5(serialize($params));
return self::PREFIX . $store_key . '_' . $endpoint . '_' . $param_hash;
}
/**
* Get cached data or fetch from API
*/
public static function get_or_fetch(
string $endpoint,
array $params,
callable $fetch_callback,
int $ttl = self::TTL_MENU
) {
$key = self::build_key($endpoint, $params);
// Try cache first
$cached = get_transient($key);
if ($cached !== false) {
return $cached;
}
// Fetch fresh data
$data = $fetch_callback();
if (!is_wp_error($data)) {
set_transient($key, $data, $ttl);
}
return $data;
}
/**
* Clear all plugin caches
*/
public static function clear_all(): void {
global $wpdb;
$wpdb->query(
"DELETE FROM {$wpdb->options}
WHERE option_name LIKE '_transient_" . self::PREFIX . "%'
OR option_name LIKE '_transient_timeout_" . self::PREFIX . "%'"
);
}
}
5.4 Cache Key Examples
cbm_deeply-rooted_menu_d41d8cd98f00b204e9800998ecf8427e
cbm_deeply-rooted_menu_7d793037a0760186574b0282f2f435e7 // With filters
cbm_deeply-rooted_specials_d41d8cd98f00b204e9800998ecf8427e
cbm_deeply-rooted_categories_d41d8cd98f00b204e9800998ecf8427e
6. Elementor Widget → API Mapping
6.1 Menu Widget
Widget Controls:
| Control | Type | Default | Maps To |
|---|---|---|---|
| Category | Select | All | ?category= |
| Brand | Text | - | ?brand= |
| Sort By | Select | Name A-Z | ?sort= |
| Products Per Page | Number | 50 | ?per_page= |
| In Stock Only | Toggle | Yes | ?in_stock=true |
| Show Images | Toggle | Yes | - (frontend only) |
| Show Prices | Toggle | Yes | - (frontend only) |
| Show THC | Toggle | Yes | - (frontend only) |
| Show Special Badge | Toggle | Yes | - (frontend only) |
| Columns | Number | 4 | - (frontend only) |
API Call:
GET /api/stores/{store_key}/menu
?category={category}
&brand={brand}
&sort={sort}
&per_page={per_page}
&in_stock={in_stock}
&page=1
Fields Used from Response:
// From ProductForStore
{
product_id, // For data attributes
slug, // For URL generation
full_name, // Display name
brand_name, // Brand label
category, // Category badge
strain_type, // Strain indicator
weight, // Size label
thc_percent, // THC display
in_stock, // Stock badge
price_cents, // Price display
regular_price_cents, // Strikethrough price
is_on_special, // Special styling
special.badge_text, // Special badge
image_url, // Product image
dutchie_url // Link target
}
6.2 Specials Widget
Widget Controls:
| Control | Type | Default | Maps To |
|---|---|---|---|
| Heading | Text | "Today's Specials" | - (frontend) |
| Category | Select | All | ?category= |
| Brand | Text | - | ?brand= |
| Deal Type | Select | All | ?special_type= |
| Limit | Number | 8 | ?limit= |
| Show Original Price | Toggle | Yes | - (frontend) |
| Show Savings | Toggle | Yes | - (frontend) |
| Layout | Select | Grid | - (frontend) |
API Call:
GET /api/stores/{store_key}/specials
?category={category}
&brand={brand}
&special_type={special_type}
&limit={limit}
Fields Used:
{
product_id,
slug,
full_name,
brand_name,
category,
strain_type,
weight,
thc_percent,
price_cents, // Current price
regular_price_cents, // Strikethrough
special.type, // For styling
special.text, // Deal description
special.badge_text, // Badge
special.savings_cents, // Savings display
image_url,
dutchie_url
}
6.3 Carousel Widget
Widget Controls:
| Control | Type | Default | Maps To |
|---|---|---|---|
| Heading | Text | "Featured Products" | - (frontend) |
| Source | Select | All Products | Endpoint selection |
| Category | Select | All | ?category= |
| Specials Only | Toggle | No | ?specials_only=true |
| Brand | Text | - | ?brand= |
| Limit | Number | 12 | ?limit= |
| Slides to Show | Number | 4 | - (frontend) |
| Autoplay | Toggle | No | - (frontend) |
| Autoplay Speed | Number | 3000 | - (frontend) |
| Show Arrows | Toggle | Yes | - (frontend) |
| Show Dots | Toggle | Yes | - (frontend) |
API Call:
// If Specials Only = true:
GET /api/stores/{store_key}/specials
?category={category}
&brand={brand}
&limit={limit}
// Otherwise:
GET /api/stores/{store_key}/products
?category={category}
&brand={brand}
&specials_only={specials_only}
&in_stock=true
&per_page={limit}
Fields Used:
{
product_id,
slug,
full_name,
brand_name,
price_cents,
regular_price_cents,
is_on_special,
special.badge_text,
image_url,
dutchie_url
}
6.4 Category Navigation Widget
Widget Controls:
| Control | Type | Default | Maps To |
|---|---|---|---|
| Layout | Select | Grid | - (frontend) |
| Show Product Count | Toggle | Yes | - (frontend) |
| Show Icons | Toggle | Yes | - (frontend) |
| Link Behavior | Select | Filter Menu | - (frontend) |
API Call:
GET /api/stores/{store_key}/categories
Fields Used:
{
id,
name,
slug,
icon,
product_count,
in_stock_count,
on_special_count,
children[]
}
7. Shortcode → API Mapping
7.1 Menu Shortcode
[cannabrands_menu
category="flower"
brand="Raw Garden"
sort="price_asc"
limit="24"
columns="4"
show_images="yes"
show_price="yes"
show_thc="yes"
in_stock="yes"
]
Maps to:
GET /api/stores/{store_key}/menu
?category=flower
&brand=Raw+Garden
&sort=price_asc
&per_page=24
&in_stock=true
7.2 Specials Shortcode
[cannabrands_specials
category="pre-rolls"
brand="Thunder Bud"
type="percent_off"
limit="8"
layout="grid"
show_savings="yes"
]
Maps to:
GET /api/stores/{store_key}/specials
?category=pre-rolls
&brand=Thunder+Bud
&special_type=percent_off
&limit=8
7.3 Carousel Shortcode
[cannabrands_carousel
category="vapes"
specials_only="true"
limit="10"
visible="4"
autoplay="yes"
speed="3000"
]
Maps to:
GET /api/stores/{store_key}/specials
?category=vapes
&limit=10
7.4 Categories Shortcode
[cannabrands_categories
layout="grid"
columns="4"
show_count="yes"
show_icons="yes"
]
Maps to:
GET /api/stores/{store_key}/categories
7.5 Single Product Shortcode
[cannabrands_product slug="thunder-bud-1g-pre-roll" layout="card"]
Maps to:
GET /api/stores/{store_key}/product/thunder-bud-1g-pre-roll
8. Error Responses
8.1 Standard Error Format
{
"error": {
"code": "STORE_NOT_FOUND",
"message": "No store found with key: invalid-store",
"status": 404
}
}
8.2 Error Codes
| Code | HTTP Status | Description |
|---|---|---|
AUTH_REQUIRED |
401 | Missing X-Store-API-Key header |
INVALID_API_KEY |
401 | API key not found or inactive |
STORE_MISMATCH |
403 | API key doesn't match requested store |
RATE_LIMITED |
429 | Rate limit exceeded |
STORE_NOT_FOUND |
404 | Store key not found |
PRODUCT_NOT_FOUND |
404 | Product slug not found |
INVALID_PARAMS |
400 | Invalid query parameters |
INTERNAL_ERROR |
500 | Server error |
8.3 Rate Limit Response
{
"error": {
"code": "RATE_LIMITED",
"message": "Rate limit exceeded. Try again in 42 seconds.",
"status": 429,
"retry_after": 42
}
}
Headers:
HTTP/1.1 429 Too Many Requests
Retry-After: 42
X-RateLimit-Limit: 1000
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1709139600
9. Data Source Summary
9.1 Tables Used by Store API
| Table | Used For |
|---|---|
stores |
Store info (name, slug, location) |
products |
Product data (name, price, THC, images, etc.) |
categories |
Category tree and counts |
canonical_brands |
Brand ID lookups (optional) |
store_api_keys |
Authentication |
9.2 Tables NOT Used by Store API
| Table | Belongs To |
|---|---|
brand_daily_metrics |
Brand Intelligence only |
brand_promo_daily_metrics |
Brand Intelligence only |
brand_store_events |
Brand Intelligence only |
brand_store_presence |
Brand Intelligence only |
store_products |
Brand Intelligence only |
store_product_snapshots |
Brand Intelligence only |
crawl_runs |
Internal crawler only |
9.3 Data Flow Diagram
┌─────────────────────────────────────────────────────────────┐
│ STORE API REQUEST │
│ GET /api/stores/deeply-rooted/menu │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ AUTH MIDDLEWARE │
│ • Validate X-Store-API-Key │
│ • Check store_api_keys table │
│ • Verify store_key matches │
│ • Check rate limits │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ QUERY BUILDER │
│ • Build SQL from query params │
│ • Apply filters (category, brand, search, in_stock) │
│ • Apply sorting │
│ • Apply pagination │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ DATABASE QUERY │
│ │
│ SELECT FROM: │
│ • products (main data) │
│ • categories (category info) │
│ • canonical_brands (brand_id lookup) │
│ │
│ NOT FROM: │
│ • brand_daily_metrics │
│ • brand_store_events │
│ • store_product_snapshots │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ RESPONSE BUILDER │
│ • Transform to ProductForStore format │
│ • Parse special text │
│ • Build image URLs │
│ • Add cache headers │
└────────────────────────────┬────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ JSON RESPONSE │
│ { store: {...}, meta: {...}, products: [...] } │
└─────────────────────────────────────────────────────────────┘
Document Version
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0 | 2025-02-28 | Claude | Initial Store API specification |
| 1.1 | 2025-11-30 | Claude | Added API-layer specials detection (Section 4). Crawler is frozen - all specials detection now happens at query time. |