Skip to main content

Overview

The Shopping Agent API provides two endpoints: one to initiate a real-time menu scan via a Trigger.dev task, and one to retrieve a cached scan result. The scan is an asynchronous operation. Web clients can request a Trigger.dev trigger token for useRealtimeTaskTrigger; native clients set triggerServerSide: true and subscribe to the returned run with the publicAccessToken.

Endpoints

EndpointMethodDescription
/api/v1/shopping/scan-tokenPOSTCreate a Trigger.dev token and initiate a menu scan
/api/v1/shopping/scan/:domain/cachedGETRetrieve a cached scan result for a dispensary domain

POST /api/v1/shopping/scan-token

Creates a public Trigger.dev access token and triggers the shopping-menu-scan task. If a valid cached scan already exists for the requested dispensary, the token is returned alongside the cached result and no new task is started. If a scan for the same dispensary is already running (de-duplication check), the token for that active run is returned instead.

Request

curl -X POST "https://api.thisiswhyimhigh.com/api/v1/shopping/scan-token" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{
    "dispensaryName": "Green Thump Dispensary",
    "websiteDomain": "greenthumpdispensary.com",
    "menuUrls": ["https://greenthumpdispensary.com/menu"],
    "categories": ["flower", "edibles"],
    "userId": "user_abc123",
    "forceRefresh": false,
    "triggerServerSide": true,
    "useInteract": false
  }'

Request Body Schema

{
  /** Display name of the dispensary */
  dispensaryName: string;

  /** Optional domain. If omitted, the API derives it from the first menu URL. */
  websiteDomain?: string;

  /** One or more menu page URLs to scan */
  menuUrls: string[];

  /** Optional product categories to filter and cache separately */
  categories?: string[];

  /** Authenticated user ID for personalization */
  userId: string;

  /** Skip cache and force a new scan */
  forceRefresh?: boolean;

  /** Native clients set this true so the API triggers the task server-side */
  triggerServerSide?: boolean;

  /** Also run Firecrawl /interact for age gates, location selectors, and iframes */
  useInteract?: boolean;
}

Responses

200 OK — Cache Hit A valid scan was found in cache. No new task is triggered.
{
  "success": true,
  "data": {
    "triggerToken": null,
    "cachedData": {
      "website_domain": "greenthumpdispensary.com",
      "categories_hash": "abc123",
      "scan_results": { "...": "cached task output" },
      "expires_at": "2026-02-18T18:00:00.000Z"
    },
    "existingRunId": null,
    "isCached": true,
    "domain": "greenthumpdispensary.com",
    "dispensaryName": "Green Thump Dispensary"
  }
}
200 OK — New Scan Triggered (Native / Server-Side Trigger) triggerServerSide: true starts the Trigger.dev run from the API and returns a scoped token for subscribing to that run.
{
  "success": true,
  "data": {
    "triggerToken": null,
    "publicAccessToken": "tr_pat_...",
    "runId": "run_abc123xyz",
    "existingRunId": null,
    "cachedData": null,
    "isCached": false,
    "domain": "greenthumpdispensary.com",
    "dispensaryName": "Green Thump Dispensary",
    "categoriesHash": "abc123",
    "message": "Scan triggered server-side - use publicAccessToken to subscribe via SSE"
  }
}
200 OK — Trigger Token Ready (Web / Client-Side Trigger) When triggerServerSide is omitted or false, the API returns a short-lived token that the web client uses with useRealtimeTaskTrigger.
{
  "success": true,
  "data": {
    "triggerToken": "tr_ptt_...",
    "cachedData": null,
    "existingRunId": null,
    "isCached": false,
    "domain": "greenthumpdispensary.com",
    "dispensaryName": "Green Thump Dispensary",
    "categoriesHash": "abc123",
    "menuUrls": ["https://greenthumpdispensary.com/menu"],
    "message": "Token ready - use with useRealtimeTaskTrigger hook"
  }
}
200 OK — Deduplication A scan for the same domain and category set is already in progress. Subscribe to the returned existing run.
{
  "success": true,
  "data": {
    "triggerToken": null,
    "cachedData": null,
    "existingRunId": "run_abc123xyz",
    "publicAccessToken": "tr_pat_...",
    "isCached": false,
    "domain": "greenthumpdispensary.com",
    "dispensaryName": "Green Thump Dispensary",
    "message": "Scan already in progress - use publicAccessToken to subscribe"
  }
}
The underlying task output keeps the domain under websiteDomain:
{
  "success": true,
  "cached": false,
  "dispensaryName": "Green Thump Dispensary",
  "websiteDomain": "greenthumpdispensary.com",
  "products": [ /* Raw menu products */ ],
  "matchedProducts": [ /* Matched menu products with strain data */ ],
  "unmatchedProducts": [ /* Products queued as discoveries */ ],
  "recommendations": [ /* See Recommendation schema below */ ],
  "screenshotUrl": "https://...",
  "stats": {
    "productsCount": 84,
    "matchedCount": 71,
    "unmatchedCount": 13
  }
}
400 Bad Request
{
  "success": false,
  "error": {
    "code": "BAD_REQUEST",
    "message": "Dispensary name required",
    "details": { "...": "validation details" }
  }
}
401 Unauthorized
{
  "success": false,
  "error": {
    "code": "UNAUTHORIZED",
    "message": "Authentication required"
  }
}

GET /api/v1/shopping/scan/:domain/cached

Retrieves the most recent cached scan result for a dispensary domain. Returns 404 if no valid cache entry exists or if the cache has expired (4-hour TTL).

Request

curl "https://api.thisiswhyimhigh.com/api/v1/shopping/scan/greenthumpdispensary.com/cached" \
  -H "Authorization: Bearer <token>"

Path Parameters

ParameterTypeDescription
domainstringThe root domain of the dispensary (e.g., greenthumpdispensary.com)

Response

200 OK — Cache Hit
{
  "success": true,
  "data": {
    "website_domain": "greenthumpdispensary.com",
    "categories_hash": "abc123",
    "products_count": 84,
    "matched_count": 71,
    "unmatched_count": 13,
    "scan_results": { "...": "cached task output" },
    "expires_at": "2026-02-18T18:00:00.000Z"
  }
}
404 Not Found — No Cache
{
  "success": false,
  "error": {
    "code": "NOT_FOUND",
    "message": "Cached scan not found: greenthumpdispensary.com"
  }
}

Data Schemas

Product

A matched menu product with strain data and personalization tags.
interface Product {
  /** Raw product name from the dispensary menu */
  rawName: string;

  /** Normalized display name */
  displayName: string;

  /** Product category */
  category: 'Flower' | 'Concentrate' | 'Edible' | 'Vape' | 'Pre-Roll' | 'Topical' | 'Tincture' | 'Other';

  /** Price as extracted from the menu (may include unit, e.g. "$45/8th") */
  price: string | null;

  /** Whether the product was matched to a strain in the database */
  matched: boolean;

  /** Confidence level of the strain match */
  matchConfidence: 'high' | 'medium' | 'low' | null;

  /** The matched strain's database ID */
  strainId: string | null;

  /** The matched strain's URL slug */
  strainSlug: string | null;

  /** The matched strain's display name */
  strainName: string | null;

  /** High Family of the matched strain */
  highFamily: string | null;

  /** Personalization tags applied to this product */
  tags: PersonalizationTag[];
}

type PersonalizationTag =
  | 'favorite_in_stock'
  | 'running_low'
  | 'bought_before'
  | 'similar_to_favorite'
  | 'matches_preferences'
  | 'new_discovery'
  | 'great_deal';

Recommendation

An AI-generated top recommendation from the current menu.
interface Recommendation {
  /** The product being recommended */
  product: Product;

  /** Plain-English explanation of why this product was recommended for this user */
  reason: string;

  /** Ranking position (1 = top recommendation) */
  rank: number;
}

Discovery

An unmatched product flagged for potential database addition.
interface Discovery {
  /** Raw product name from the menu */
  rawName: string;

  /** Product category */
  category: string;

  /** Price as displayed on the menu */
  price: string | null;

  /** Whether this strain has already been queued for research */
  queued: boolean;
}

Real-Time Progress

The mobile app does not poll for scan results. Native clients call /scan-token with triggerServerSide: true, then subscribe to the returned runId using publicAccessToken and Trigger.dev realtime SSE. Web clients can use the returned triggerToken with useRealtimeTaskTrigger and submit the task input client-side.

Progress Event Shape

Trigger.dev emits run metadata updates as the task progresses. The Shopping Agent task emits structured metadata at each stage:
interface ScanProgressMetadata {
  currentStage:
    | 'cache-check'
    | 'scrape'
    | 'match'
    | 'queue-unmatched'
    | 'personalize'
    | 'cache-save'
    | 'complete';
  progress: number;       // 0–100
  status?: 'starting' | 'cached' | 'no-products' | 'success' | string;
  dispensaryName?: string;
  domain?: string;
  runId?: string;
  cacheHit?: boolean;
  useInteract?: boolean;
  screenshotUrl?: string;
  extractionMethod?: string;
}

Native Integration

async function startNativeScan() {
  const response = await fetch('/api/v1/shopping/scan-token', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Authorization: 'Bearer <token>',
    },
    body: JSON.stringify({
      dispensaryName: 'Green Thump Dispensary',
      websiteDomain: 'greenthumpdispensary.com',
      menuUrls: ['https://greenthumpdispensary.com/menu'],
      userId: 'user_abc123',
      triggerServerSide: true,
    }),
  });

  const { data } = await response.json();
  if (data.isCached) return data.cachedData;

  subscribeToTriggerRun({
    runId: data.runId ?? data.existingRunId,
    publicAccessToken: data.publicAccessToken,
  });
}

Web Integration

import { useState } from 'react';
import { useRealtimeTaskTrigger } from '@trigger.dev/react-hooks';

function ScanScreen({ dispensaryName, websiteDomain, menuUrls, userId }) {
  const [triggerToken, setTriggerToken] = useState<string | null>(null);
  const { submit, run } = useRealtimeTaskTrigger('shopping-menu-scan', {
    accessToken: triggerToken || undefined,
    enabled: !!triggerToken,
  });

  const startScan = async () => {
    const { data } = await fetchScanToken({
      dispensaryName,
      websiteDomain,
      menuUrls,
      userId,
    });

    setTriggerToken(data.triggerToken);
    submit({
      dispensaryName,
      websiteDomain,
      menuUrls,
      categories: [],
      userId,
      forceRefresh: false,
      useInteract: false,
    });
  };

  const metadata = run?.metadata as ScanProgressMetadata | undefined;

  return <ProgressBar progress={metadata?.progress ?? 0} />;
}

The shopping-menu-scan Trigger.dev Task

The Trigger.dev task runs in the @tiwih/trigger package and executes six stages in sequence.

Task ID

shopping-menu-scan

Trigger.dev Dashboard

View task in dashboard

Stage Details

StageProgress RangeDescription
cache-check0–5%Queries menu_scans in Supabase for an unexpired entry matching website_domain
scrape5–40%Tool-based extraction with cascading fallback: Extract (wildcard) → Scrape+JSON → Scrape+AI. Supports multi-page menus via wildcard URL patterns. Falls back to sitemap discovery if initial extraction returns 0 products.
match40–60%Three-tier strain matching: exact → slug → trigram (pg_trgm similarity > 0.4)
personalize60–85%Claude Sonnet generates ranked recommendations using user context and matched strain data
cache-save85–95%Upserts results into menu_scans with a 4-hour TTL
complete100%Returns the full payload as task output

Input Schema

interface ShoppingMenuScanInput {
  dispensaryName: string;
  websiteDomain: string;
  menuUrls: string[];
  categories: string[];
  userId: string;
  forceRefresh: boolean;
  useInteract: boolean;
  userContext?: {
    favorites: Array<{
      name: string;
      slug: string | null;
      strainType: string | null;
    }>;
    stashItems: Array<{
      name: string;
      slug: string | null;
      remainingGrams: number | null;
      strainType: string | null;
    }>;
    recentOrders: Array<{
      name: string;
      slug: string | null;
      purchasedAt: string;
    }>;
    dislikes: Array<{
      name: string;
      slug: string | null;
    }>;
    preferences: {
      preferredTypes: string[];
      thcRange: { min: number; max: number } | null;
      budgetRange: { min: number; max: number } | null;
      preferredCategories: string[];
    } | null;
  };
}

Output Schema

interface ShoppingMenuScanOutput {
  success: boolean;
  cached: boolean;
  dispensaryName: string;
  websiteDomain: string;
  scannedAt: string;          // ISO 8601 timestamp
  expiresAt: string;          // ISO 8601 timestamp (scannedAt + 4 hours)
  products: Product[];
  matchedProducts: Product[];
  unmatchedProducts: Product[];
  recommendations: Recommendation[];
  screenshotUrl: string | null;
  stats: {
    productsCount: number;
    matchedCount: number;
    unmatchedCount: number;
  };
}

Strain Matching Algorithm

The match stage runs three passes in sequence, stopping as soon as a match is found for each product.

Pass 1: Exact Match

SELECT id, slug, name_display
FROM strains_v2
WHERE name_canonical = lower(trim($1))
LIMIT 1;
Confidence: high

Pass 2: Slug Match

SELECT id, slug, name_display
FROM strains_v2
WHERE slug = slugify($1)
LIMIT 1;
Confidence: high

Pass 3: Trigram Match

SELECT id, slug, name_display,
       similarity(name_canonical, lower(trim($1))) AS sim
FROM strains_v2
WHERE similarity(name_canonical, lower(trim($1))) > 0.4
ORDER BY sim DESC
LIMIT 1;
Confidence: medium if similarity > 0.6, low if 0.4–0.6 Products with no match across all three passes are placed in the discoveries array.

Database

Scan results are stored in the menu_scans table in Supabase.

Table: menu_scans

ColumnTypeDescription
iduuidPrimary key
website_domaintextDispensary root domain
categories_hashtextHash of extracted categories (used for deduplication)
scanned_attimestamptzWhen the scan was performed
expires_attimestamptzCache expiry (scanned_at + 4 hours)
total_productsintTotal extracted products
matched_countintProducts matched to a strain
unmatched_countintProducts with no strain match
products_jsonjsonbFull product array
recommendations_jsonjsonbAI recommendations array
discoveries_jsonjsonbUnmatched discoveries array
Unique constraint: UNIQUE (website_domain, categories_hash) — prevents duplicate scans for the same dispensary and menu composition.

Error Codes

CodeHTTP StatusDescription
VALIDATION_ERROR400Missing or invalid request fields
UNAUTHORIZED401Missing or invalid auth token
MENU_NOT_ACCESSIBLE422The menu URL could not be reached or scraped
SCAN_TIMEOUT408The Firecrawl agent did not complete within the time limit
TRIGGER_UNAVAILABLE503Trigger.dev task could not be initiated