# Cannabrands Dashboard API - Front-End Ready Specification ## Overview This document defines the **front-end-ready API responses** for the Cannabrands brand dashboard. All responses are optimized for direct consumption by dashboard cards, widgets, and chart components. **Base URL:** `https://api.cannabrands.app/v1` **Authentication:** Bearer token (JWT) with `brand_key` claim --- ## 1. Brand Key Resolution Flow ### Resolution Steps ``` API Request: GET /v1/brands/cb_12345/dashboard/summary ↓ brand_key = "cb_12345" ↓ ┌───────────────┴───────────────┐ │ 1. JWT Validation │ │ Extract brand_key from │ │ token claims │ │ Verify: token.brand_key │ │ === "cb_12345" │ └───────────────┬───────────────┘ ↓ ┌───────────────┴───────────────┐ │ 2. Brand Resolution │ │ Step A: Try cannabrands_id│ │ SELECT id FROM │ │ canonical_brands WHERE │ │ cannabrands_id = │ │ 'cb_12345' │ │ │ │ Step B (fallback): Try │ │ cannabrands_slug │ │ SELECT id FROM │ │ canonical_brands WHERE │ │ cannabrands_slug = │ │ 'cb_12345' │ └───────────────┬───────────────┘ ↓ canonical_brand_id = 42 ↓ ┌───────────────┴───────────────┐ │ 3. All Queries Scoped │ │ WHERE canonical_brand_id │ │ = 42 │ └───────────────────────────────┘ ``` ### TypeScript Implementation ```typescript async function resolveBrandKey(db: Pool, brandKey: string): Promise { // Step A: Try cannabrands_id (primary identifier) const byId = await db.query( `SELECT id FROM canonical_brands WHERE cannabrands_id = $1`, [brandKey] ); if (byId.rows.length > 0) { return byId.rows[0].id; } // Step B: Fallback to cannabrands_slug const bySlug = await db.query( `SELECT id FROM canonical_brands WHERE cannabrands_slug = $1`, [brandKey] ); if (bySlug.rows.length > 0) { return bySlug.rows[0].id; } return null; } ``` --- ## 2. Dashboard Summary Endpoint ### `GET /v1/brands/:brand_key/dashboard/summary` Returns all KPIs needed for the main dashboard cards in a single request. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `window` | string | No | `30d` | Comparison window: `7d`, `30d`, `90d` | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "brand_name": "Raw Garden", "as_of": "2025-01-15T08:00:00.000Z", "window": "30d", "store_footprint": { "current": 127, "previous": 112, "delta": 15, "delta_percent": 13.39, "direction": "up" }, "active_skus": { "current": 543, "previous": 476, "delta": 67, "delta_percent": 14.08, "direction": "up" }, "in_stock_rate": { "current": 90.1, "previous": 88.7, "delta": 1.4, "direction": "up" }, "promo_exposure": { "skus_on_promo": 34, "stores_running_promos": 23, "percent_of_skus": 6.3, "direction": "stable" }, "price_summary": { "avg_cents": 4250, "min_cents": 2500, "max_cents": 8500, "avg_formatted": "$42.50", "min_formatted": "$25.00", "max_formatted": "$85.00" }, "activity_last_7d": { "stores_added": 3, "stores_removed": 0, "new_skus": 12, "removed_skus": 2, "price_changes": 8 } } ``` **Dashboard Card Mapping:** | Card | Fields | |------|--------| | "Store Distribution" | `store_footprint.current`, `store_footprint.delta`, `store_footprint.direction` | | "Active SKUs" | `active_skus.current`, `active_skus.delta`, `active_skus.direction` | | "In-Stock Rate" | `in_stock_rate.current` + "%" suffix, `in_stock_rate.direction` | | "Promotional Activity" | `promo_exposure.skus_on_promo`, `promo_exposure.stores_running_promos` | | "Avg Price" | `price_summary.avg_formatted` | | "Recent Activity" | `activity_last_7d.*` | **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "brand_name": "New Brand", "as_of": "2025-01-15T08:00:00.000Z", "window": "30d", "store_footprint": { "current": 0, "previous": 0, "delta": 0, "delta_percent": 0, "direction": "stable" }, "active_skus": { "current": 0, "previous": 0, "delta": 0, "delta_percent": 0, "direction": "stable" }, "in_stock_rate": { "current": null, "previous": null, "delta": null, "direction": "stable" }, "promo_exposure": { "skus_on_promo": 0, "stores_running_promos": 0, "percent_of_skus": 0, "direction": "stable" }, "price_summary": { "avg_cents": null, "min_cents": null, "max_cents": null, "avg_formatted": null, "min_formatted": null, "max_formatted": null }, "activity_last_7d": { "stores_added": 0, "stores_removed": 0, "new_skus": 0, "removed_skus": 0, "price_changes": 0 } } ``` **Direction Values:** | Value | Meaning | UI Treatment | |-------|---------|--------------| | `"up"` | Increased from previous period | Green arrow, positive indicator | | `"down"` | Decreased from previous period | Red arrow, negative indicator | | `"stable"` | No significant change | Gray dash, neutral indicator | --- ## 3. Store Timeseries Endpoint ### `GET /v1/brands/:brand_key/dashboard/store-timeseries` Returns daily store/SKU data points optimized for line/area charts. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `from` | string | No | 30 days ago | ISO date `YYYY-MM-DD` | | `to` | string | No | today | ISO date `YYYY-MM-DD` | | `interval` | string | No | `day` | Aggregation: `day`, `week`, `month` | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "period": { "from": "2024-12-16", "to": "2025-01-15", "interval": "day", "points_count": 31 }, "points": [ { "date": "2024-12-16", "stores": 112, "stores_added": 2, "stores_removed": 0, "stores_net": 2, "skus": 476, "skus_in_stock": 423, "skus_new": 5, "skus_removed": 1, "skus_net": 4 }, { "date": "2024-12-17", "stores": 114, "stores_added": 2, "stores_removed": 0, "stores_net": 2, "skus": 482, "skus_in_stock": 430, "skus_new": 8, "skus_removed": 2, "skus_net": 6 } ], "summary": { "stores_start": 112, "stores_end": 127, "stores_peak": 128, "stores_low": 112, "skus_start": 476, "skus_end": 543, "skus_peak": 545, "skus_low": 476 } } ``` **Chart Component Usage:** ```typescript // Example: Recharts line chart const storeChartData = response.points.map(p => ({ date: p.date, stores: p.stores, skus: p.skus })); ``` **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "period": { "from": "2024-12-16", "to": "2025-01-15", "interval": "day", "points_count": 0 }, "points": [], "summary": { "stores_start": null, "stores_end": null, "stores_peak": null, "stores_low": null, "skus_start": null, "skus_end": null, "skus_peak": null, "skus_low": null } } ``` --- ## 4. Promo Timeseries Endpoint ### `GET /v1/brands/:brand_key/dashboard/promo-timeseries` Returns daily promotional activity data points for stacked bar/area charts. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `from` | string | No | 30 days ago | ISO date `YYYY-MM-DD` | | `to` | string | No | today | ISO date `YYYY-MM-DD` | | `interval` | string | No | `day` | Aggregation: `day`, `week`, `month` | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "period": { "from": "2024-12-16", "to": "2025-01-15", "interval": "day", "points_count": 31 }, "points": [ { "date": "2024-12-16", "total_promos": 45, "percent_off": 28, "dollar_off": 5, "bogo": 8, "bundle": 2, "set_price": 1, "other": 1, "avg_discount_percent": 18.5, "stores_with_promos": 23 }, { "date": "2024-12-17", "total_promos": 52, "percent_off": 32, "dollar_off": 6, "bogo": 10, "bundle": 2, "set_price": 1, "other": 1, "avg_discount_percent": 19.2, "stores_with_promos": 27 } ], "summary": { "total_promo_days": 45, "peak_promos": 67, "peak_date": "2024-12-24", "avg_daily_promos": 48.2, "most_common_type": "percent_off", "avg_discount_percent": 18.8 } } ``` **Stacked Bar Chart Usage:** ```typescript // Promo breakdown stacked bar const promoTypes = ['percent_off', 'dollar_off', 'bogo', 'bundle', 'set_price', 'other']; {promoTypes.map((type, i) => ( ))} ``` **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "period": { "from": "2024-12-16", "to": "2025-01-15", "interval": "day", "points_count": 0 }, "points": [], "summary": { "total_promo_days": 0, "peak_promos": null, "peak_date": null, "avg_daily_promos": 0, "most_common_type": null, "avg_discount_percent": null } } ``` --- ## 5. Store Changes Endpoint ### `GET /v1/brands/:brand_key/dashboard/store-changes` Returns recent store additions and removals for "New Stores" / "Lost Stores" widgets. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `days` | number | No | `30` | Lookback period (max 90) | | `limit` | number | No | `10` | Max results per list | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "period": { "days": 30, "from": "2024-12-16", "to": "2025-01-15" }, "stores_added": { "count": 15, "list": [ { "store_id": 142, "store_name": "Green Thumb LA", "store_slug": "green-thumb-la", "dispensary_name": "Green Thumb Dispensary", "city": "Los Angeles", "state": "CA", "date_added": "2025-01-14", "days_ago": 1, "initial_sku_count": 12 }, { "store_id": 156, "store_name": "Harvest House SF", "store_slug": "harvest-house-sf", "dispensary_name": "Harvest House", "city": "San Francisco", "state": "CA", "date_added": "2025-01-12", "days_ago": 3, "initial_sku_count": 8 } ] }, "stores_removed": { "count": 2, "list": [ { "store_id": 78, "store_name": "MedMen Venice", "store_slug": "medmen-venice", "dispensary_name": "MedMen", "city": "Venice", "state": "CA", "date_removed": "2025-01-10", "days_ago": 5, "tenure_days": 156, "last_sku_count": 6 } ] }, "stores_reappeared": { "count": 1, "list": [ { "store_id": 92, "store_name": "The Cannabist - Sacramento", "store_slug": "cannabist-sacramento", "dispensary_name": "The Cannabist", "city": "Sacramento", "state": "CA", "date_reappeared": "2025-01-08", "days_ago": 7, "days_absent": 21, "sku_count": 4 } ] } } ``` **Widget Mapping:** | Widget | Data Source | |--------|-------------| | "New Stores" badge count | `stores_added.count` | | "New Stores" list | `stores_added.list` | | "Lost Stores" badge count | `stores_removed.count` | | "Lost Stores" list | `stores_removed.list` | | "Returned" badge count | `stores_reappeared.count` | **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "period": { "days": 30, "from": "2024-12-16", "to": "2025-01-15" }, "stores_added": { "count": 0, "list": [] }, "stores_removed": { "count": 0, "list": [] }, "stores_reappeared": { "count": 0, "list": [] } } ``` --- ## 6. Stores Drill-Down Endpoint ### `GET /v1/brands/:brand_key/dashboard/stores` Returns all stores carrying this brand with current metrics for a detailed table view. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `status` | string | No | `active` | Filter: `active`, `removed`, `all` | | `state` | string | No | - | Filter by state code (e.g., `CA`) | | `sort` | string | No | `sku_count` | Sort: `sku_count`, `name`, `date_added`, `tenure` | | `order` | string | No | `desc` | Order: `asc`, `desc` | | `limit` | number | No | `50` | Max results (max 200) | | `offset` | number | No | `0` | Pagination offset | | `search` | string | No | - | Search store/dispensary name | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "filters": { "status": "active", "state": null, "search": null }, "pagination": { "total": 127, "limit": 50, "offset": 0, "has_more": true }, "stores": [ { "store_id": 42, "store_name": "Green Thumb LA", "store_slug": "green-thumb-la", "dispensary_name": "Green Thumb Dispensary", "city": "Los Angeles", "state": "CA", "dutchie_url": "https://dutchie.com/dispensary/green-thumb-la", "presence": { "status": "active", "first_seen": "2024-06-15", "last_seen": "2025-01-15", "tenure_days": 214 }, "sku_counts": { "active": 24, "in_stock": 21, "on_promo": 3, "total_ever": 28 }, "price_stats": { "avg_cents": 4500, "min_cents": 2500, "max_cents": 8500 }, "activity_7d": { "new_skus": 2, "removed_skus": 0, "price_changes": 1 } } ], "aggregates": { "total_stores": 127, "states_count": 8, "avg_skus_per_store": 4.3, "top_states": [ { "state": "CA", "count": 45 }, { "state": "CO", "count": 28 }, { "state": "WA", "count": 18 } ] } } ``` **Table Column Mapping:** | Column | Field | |--------|-------| | Store Name | `store_name` (link to `dutchie_url`) | | Location | `city`, `state` | | SKUs | `sku_counts.active` | | In Stock | `sku_counts.in_stock` | | On Promo | `sku_counts.on_promo` | | Avg Price | `price_stats.avg_cents` / 100 | | Since | `presence.first_seen` | | Tenure | `presence.tenure_days` + " days" | **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "filters": { "status": "active", "state": null, "search": null }, "pagination": { "total": 0, "limit": 50, "offset": 0, "has_more": false }, "stores": [], "aggregates": { "total_stores": 0, "states_count": 0, "avg_skus_per_store": 0, "top_states": [] } } ``` --- ## 7. Products Drill-Down Endpoint ### `GET /v1/brands/:brand_key/dashboard/products` Returns all products for this brand with lifecycle and pricing data. **Query Parameters:** | Param | Type | Required | Default | Description | |-------|------|----------|---------|-------------| | `store_slug` | string | No | - | Filter by specific store | | `status` | string | No | `active` | Filter: `active`, `stale`, `removed`, `all` | | `in_stock` | string | No | - | Filter: `true`, `false` | | `on_promo` | string | No | - | Filter: `true`, `false` | | `sort` | string | No | `store_count` | Sort: `store_count`, `name`, `price`, `date_added` | | `order` | string | No | `desc` | Order: `asc`, `desc` | | `limit` | number | No | `50` | Max results (max 200) | | `offset` | number | No | `0` | Pagination offset | | `search` | string | No | - | Search product name | **Response (Success - 200):** ```json { "brand_key": "cb_12345", "filters": { "store_slug": null, "status": "active", "in_stock": null, "on_promo": null, "search": null }, "pagination": { "total": 543, "limit": 50, "offset": 0, "has_more": true }, "products": [ { "product_id": 12345, "fingerprint": "a1b2c3d4e5f6...", "name": "Live Resin - Slurm OG", "full_name": "Raw Garden Live Resin - Slurm OG", "variant": "1g", "weight": "1g", "distribution": { "store_count_current": 45, "store_count_previous_30d": 38, "store_count_delta": 7, "store_count_direction": "up", "states": ["CA", "CO", "WA", "NV"] }, "pricing": { "avg_price_cents": 4500, "min_price_cents": 3999, "max_price_cents": 5499, "price_spread_percent": 37.5, "avg_formatted": "$45.00", "min_formatted": "$39.99", "max_formatted": "$54.99" }, "stock": { "in_stock_count": 42, "out_of_stock_count": 3, "in_stock_percent": 93.3 }, "promos": { "stores_on_promo": 8, "percent_on_promo": 17.8, "percent_time_on_special_30d": 23.5, "common_promo_type": "percent_off" }, "lifecycle": { "first_seen": "2024-08-10", "days_tracked": 158, "status": "active" } } ], "aggregates": { "total_products": 543, "total_in_stock": 489, "total_on_promo": 34, "avg_stores_per_product": 4.2, "categories": [ { "name": "Concentrates", "count": 312 }, { "name": "Vapes", "count": 156 }, { "name": "Flower", "count": 75 } ] } } ``` **Table Column Mapping:** | Column | Field | |--------|-------| | Product | `name` | | Size | `variant` or `weight` | | Stores | `distribution.store_count_current` | | Avg Price | `pricing.avg_formatted` | | Price Range | `pricing.min_formatted` - `pricing.max_formatted` | | In Stock % | `stock.in_stock_percent` | | On Promo | `promos.stores_on_promo` stores | | First Seen | `lifecycle.first_seen` | **Response (Empty State - 200):** ```json { "brand_key": "cb_99999", "filters": { "store_slug": null, "status": "active", "in_stock": null, "on_promo": null, "search": null }, "pagination": { "total": 0, "limit": 50, "offset": 0, "has_more": false }, "products": [], "aggregates": { "total_products": 0, "total_in_stock": 0, "total_on_promo": 0, "avg_stores_per_product": 0, "categories": [] } } ``` --- ## 8. Empty State Behavior Summary All endpoints return HTTP 200 with structured empty data rather than errors when a brand has no data: | Endpoint | Empty Indicator | UI Treatment | |----------|-----------------|--------------| | `/dashboard/summary` | `store_footprint.current === 0` | Show "No data yet" card overlay | | `/dashboard/store-timeseries` | `points.length === 0` | Show empty chart with "No data" message | | `/dashboard/promo-timeseries` | `points.length === 0` | Show empty chart with "No data" message | | `/dashboard/store-changes` | All `.count === 0` | Show "No changes in this period" | | `/dashboard/stores` | `pagination.total === 0` | Show empty state illustration | | `/dashboard/products` | `pagination.total === 0` | Show empty state illustration | **Empty State Detection (TypeScript):** ```typescript function hasBrandData(summary: DashboardSummary): boolean { return summary.store_footprint.current > 0 || summary.active_skus.current > 0; } function hasTimeseriesData(data: TimeseriesResponse): boolean { return data.points.length > 0; } function hasStoreChanges(data: StoreChangesResponse): boolean { return data.stores_added.count > 0 || data.stores_removed.count > 0 || data.stores_reappeared.count > 0; } ``` --- ## 9. Error Responses All endpoints use consistent error response format: ### 401 Unauthorized ```json { "error": "Unauthorized", "code": "AUTH_REQUIRED", "message": "Valid authentication token required" } ``` ### 403 Forbidden ```json { "error": "Access denied", "code": "BRAND_ACCESS_DENIED", "message": "You do not have access to brand: cb_12345" } ``` ### 404 Brand Not Found ```json { "error": "Brand not found", "code": "BRAND_NOT_FOUND", "message": "No brand found with key: cb_unknown" } ``` ### 404 Feature Disabled ```json { "error": "Feature not available", "code": "FEATURE_DISABLED", "message": "Brand intelligence features are temporarily unavailable" } ``` ### 422 Validation Error ```json { "error": "Validation error", "code": "INVALID_PARAMS", "message": "Invalid parameters", "details": { "window": "Must be one of: 7d, 30d, 90d", "limit": "Must be between 1 and 200" } } ``` ### 500 Server Error ```json { "error": "Internal server error", "code": "INTERNAL_ERROR", "message": "An unexpected error occurred", "request_id": "req_abc123" } ``` --- ## 10. TypeScript Response Types ```typescript // Common types type Direction = 'up' | 'down' | 'stable'; interface DeltaMetric { current: number; previous: number; delta: number; delta_percent: number; direction: Direction; } interface NullableDeltaMetric { current: number | null; previous: number | null; delta: number | null; direction: Direction; } // Summary endpoint interface DashboardSummary { brand_key: string; brand_name: string; as_of: string; window: '7d' | '30d' | '90d'; store_footprint: DeltaMetric; active_skus: DeltaMetric; in_stock_rate: NullableDeltaMetric; promo_exposure: { skus_on_promo: number; stores_running_promos: number; percent_of_skus: number; direction: Direction; }; price_summary: { avg_cents: number | null; min_cents: number | null; max_cents: number | null; avg_formatted: string | null; min_formatted: string | null; max_formatted: string | null; }; activity_last_7d: { stores_added: number; stores_removed: number; new_skus: number; removed_skus: number; price_changes: number; }; } // Timeseries endpoints interface TimeseriesPeriod { from: string; to: string; interval: 'day' | 'week' | 'month'; points_count: number; } interface StoreTimeseriesPoint { date: string; stores: number; stores_added: number; stores_removed: number; stores_net: number; skus: number; skus_in_stock: number; skus_new: number; skus_removed: number; skus_net: number; } interface StoreTimeseriesResponse { brand_key: string; period: TimeseriesPeriod; points: StoreTimeseriesPoint[]; summary: { stores_start: number | null; stores_end: number | null; stores_peak: number | null; stores_low: number | null; skus_start: number | null; skus_end: number | null; skus_peak: number | null; skus_low: number | null; }; } interface PromoTimeseriesPoint { date: string; total_promos: number; percent_off: number; dollar_off: number; bogo: number; bundle: number; set_price: number; other: number; avg_discount_percent: number; stores_with_promos: number; } interface PromoTimeseriesResponse { brand_key: string; period: TimeseriesPeriod; points: PromoTimeseriesPoint[]; summary: { total_promo_days: number; peak_promos: number | null; peak_date: string | null; avg_daily_promos: number; most_common_type: string | null; avg_discount_percent: number | null; }; } // Store changes interface StoreChangeEntry { store_id: number; store_name: string; store_slug: string; dispensary_name: string; city: string; state: string; } interface StoreAddedEntry extends StoreChangeEntry { date_added: string; days_ago: number; initial_sku_count: number; } interface StoreRemovedEntry extends StoreChangeEntry { date_removed: string; days_ago: number; tenure_days: number; last_sku_count: number; } interface StoreReappearedEntry extends StoreChangeEntry { date_reappeared: string; days_ago: number; days_absent: number; sku_count: number; } interface StoreChangesResponse { brand_key: string; period: { days: number; from: string; to: string; }; stores_added: { count: number; list: StoreAddedEntry[]; }; stores_removed: { count: number; list: StoreRemovedEntry[]; }; stores_reappeared: { count: number; list: StoreReappearedEntry[]; }; } // Stores drill-down interface StoreListEntry { store_id: number; store_name: string; store_slug: string; dispensary_name: string; city: string; state: string; dutchie_url: string; presence: { status: 'active' | 'removed'; first_seen: string; last_seen: string; tenure_days: number; }; sku_counts: { active: number; in_stock: number; on_promo: number; total_ever: number; }; price_stats: { avg_cents: number; min_cents: number; max_cents: number; }; activity_7d: { new_skus: number; removed_skus: number; price_changes: number; }; } interface StoresResponse { brand_key: string; filters: { status: string; state: string | null; search: string | null; }; pagination: { total: number; limit: number; offset: number; has_more: boolean; }; stores: StoreListEntry[]; aggregates: { total_stores: number; states_count: number; avg_skus_per_store: number; top_states: { state: string; count: number }[]; }; } // Products drill-down interface ProductListEntry { product_id: number; fingerprint: string; name: string; full_name: string; variant: string; weight: string; distribution: { store_count_current: number; store_count_previous_30d: number; store_count_delta: number; store_count_direction: Direction; states: string[]; }; pricing: { avg_price_cents: number; min_price_cents: number; max_price_cents: number; price_spread_percent: number; avg_formatted: string; min_formatted: string; max_formatted: string; }; stock: { in_stock_count: number; out_of_stock_count: number; in_stock_percent: number; }; promos: { stores_on_promo: number; percent_on_promo: number; percent_time_on_special_30d: number; common_promo_type: string | null; }; lifecycle: { first_seen: string; days_tracked: number; status: 'active' | 'stale' | 'removed'; }; } interface ProductsResponse { brand_key: string; filters: { store_slug: string | null; status: string; in_stock: string | null; on_promo: string | null; search: string | null; }; pagination: { total: number; limit: number; offset: number; has_more: boolean; }; products: ProductListEntry[]; aggregates: { total_products: number; total_in_stock: number; total_on_promo: number; avg_stores_per_product: number; categories: { name: string; count: number }[]; }; } ``` --- ## Document Version | Version | Date | Author | Changes | |---------|------|--------|---------| | 1.0 | 2025-01-15 | Claude | Initial front-end ready specification |