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 — the mobile app connects to it via WebSocket using useRealtimeTaskTrigger and receives progress events as each pipeline stage completes.

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://tiwih-api.vercel.app/api/v1/shopping/scan-token" \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer <token>" \
  -d '{
    "dispensaryDomain": "greenthumpdispensary.com",
    "menuUrl": "https://greenthumpdispensary.com/menu",
    "userId": "user_abc123",
    "userContext": {
      "favoriteStrainIds": ["strain_001", "strain_042"],
      "recentStrainIds": ["strain_001", "strain_015", "strain_088"],
      "lowStashStrainIds": ["strain_015"],
      "preferredTypes": ["hybrid", "sativa"]
    }
  }'

Request Body Schema

{
  /** The root domain of the dispensary — used as the cache key */
  dispensaryDomain: string;

  /** Full URL of the menu page to scan */
  menuUrl: string;

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

  /** Optional user context for personalization tags */
  userContext?: {
    /** Strain IDs the user has favorited */
    favoriteStrainIds?: string[];

    /** Strain IDs from recent orders */
    recentStrainIds?: string[];

    /** Strain IDs where stash quantity is below threshold */
    lowStashStrainIds?: string[];

    /** User's preferred strain types */
    preferredTypes?: ('sativa' | 'indica' | 'hybrid')[];
  };
}

Responses

200 OK — Cache Hit A valid scan was found in cache. No new task is triggered.
{
  "source": "cache",
  "publicAccessToken": "tr_pat_...",
  "cachedResult": {
    "dispensaryDomain": "greenthumpdispensary.com",
    "scannedAt": "2026-02-18T14:00:00.000Z",
    "expiresAt": "2026-02-18T18:00:00.000Z",
    "totalProducts": 84,
    "matchedCount": 71,
    "unmatchedCount": 13,
    "categories": ["Flower", "Concentrates", "Edibles", "Vapes", "Pre-Rolls"],
    "products": [ /* See Product schema below */ ],
    "recommendations": [ /* See Recommendation schema below */ ],
    "discoveries": [ /* See Discovery schema below */ ]
  }
}
200 OK — New Scan Triggered No cache hit. A new Trigger.dev run has started.
{
  "source": "trigger",
  "publicAccessToken": "tr_pat_...",
  "runId": "run_abc123xyz",
  "cachedResult": null
}
200 OK — Deduplication A scan for this dispensary is already in progress. Returns the token for the active run.
{
  "source": "dedup",
  "publicAccessToken": "tr_pat_...",
  "runId": "run_abc123xyz",
  "cachedResult": null
}
400 Bad Request
{
  "error": "Missing required field: dispensaryDomain",
  "code": "VALIDATION_ERROR"
}
401 Unauthorized
{
  "error": "Authentication required",
  "code": "UNAUTHORIZED"
}

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

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

Request

curl "https://tiwih-api.vercel.app/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
{
  "found": true,
  "scannedAt": "2026-02-18T14:00:00.000Z",
  "expiresAt": "2026-02-18T18:00:00.000Z",
  "totalProducts": 84,
  "matchedCount": 71,
  "unmatchedCount": 13,
  "categories": ["Flower", "Concentrates", "Edibles", "Vapes", "Pre-Rolls"],
  "products": [ /* See Product schema below */ ],
  "recommendations": [ /* See Recommendation schema below */ ],
  "discoveries": [ /* See Discovery schema below */ ]
}
200 OK — No Cache
{
  "found": false
}

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;
}

WebSocket / Real-Time Progress

The mobile app does not poll for scan results. Instead, it subscribes to the Trigger.dev run via useRealtimeTaskTrigger from @trigger.dev/react-hooks, using the publicAccessToken returned by /scan-token.

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 {
  stage: 'cache-check' | 'scrape' | 'match' | 'personalize' | 'cache-save' | 'complete';
  progress: number;       // 0–100
  message: string;        // Human-readable status, e.g. "Matching 84 products..."
  productCount?: number;  // Available after scrape stage
  matchedCount?: number;  // Available after match stage
}

React Native Integration

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

function ScanScreen({ dispensaryDomain, menuUrl }) {
  const { submit, runs } = useRealtimeTaskTrigger('shopping-menu-scan');

  const startScan = async () => {
    const { publicAccessToken } = await fetchScanToken({ dispensaryDomain, menuUrl });
    // The token is already tied to the run — subscribe via the SDK
    submit({ dispensaryDomain, menuUrl }, { publicAccessToken });
  };

  const activeRun = runs[0];
  const metadata = activeRun?.metadata as ScanProgressMetadata | undefined;

  return (
    <View>
      <ProgressBar progress={metadata?.progress ?? 0} />
      <Text>{metadata?.message ?? 'Starting scan...'}</Text>
      {activeRun?.status === 'COMPLETED' && (
        <ResultsScreen results={activeRun.output} />
      )}
    </View>
  );
}

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 {
  dispensaryDomain: string;
  menuUrl: string;
  userId: string;
  userContext?: {
    favoriteStrainIds?: string[];
    recentStrainIds?: string[];
    lowStashStrainIds?: string[];
    preferredTypes?: string[];
  };
}

Output Schema

interface ShoppingMenuScanOutput {
  dispensaryDomain: string;
  scannedAt: string;          // ISO 8601 timestamp
  expiresAt: string;          // ISO 8601 timestamp (scannedAt + 4 hours)
  totalProducts: number;
  matchedCount: number;
  unmatchedCount: number;
  categories: string[];
  products: Product[];
  recommendations: Recommendation[];
  discoveries: Discovery[];
}

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