February 25, 2026 · 36 min read

Build a Smart Cart with Sahha

Use Sahha health scores and archetypes to auto-generate personalized product recommendations in a weekly wellness cart — powered by your database.

In this guide you will build a smart cart — a personalized weekly product bundle that adapts to a user’s health data. The flow looks like this:

Sahha Webhook Data → Your Database → Query Scores + Archetypes → Categorize Health State → Map to Product Catalog → Smart Cart UI

A user in recovery mode gets anti-inflammatory foods, collagen, and bone broth. Someone in high-training mode gets energy bars, electrolytes, and high-calorie meal kits. A user flagged for sleep-support gets chamomile tea, melatonin, and calming dinners. The cart builds itself from health data — no manual curation needed.

A smart cart bundle built for a high-training health state — four products auto-selected from the catalog based on Sahha scores and archetypes.

By the end you will have an API endpoint that accepts a Sahha profile ID and returns a categorized health state, plus a product catalog structure that maps states to curated bundles. The starter project download includes a complete React frontend with the cart UI.

Prerequisites

Before you begin, make sure you have Sahha webhook data streaming into your database — follow the guide for your platform:

This guide assumes you already have the database schema and webhook handler from the data streaming guide. The events table and webhook handler are shared across demos — you do not need to create them again.

You will also need:

  • Node.js 18+ and a package manager (npm, pnpm, or yarn)
  • A Sahha account with at least one profile generating health data — sahha.ai
Want to skip ahead? Download the starter project — it includes the Convex backend, a React frontend with the smart cart UI, product catalog, and all configuration ready to go. Just add your API keys and run it.
Download Convex starter project
The starter project includes two profile slots so you can compare carts side-by-side. If both profiles return the same health state (and therefore the same cart), it means their recent health data falls into the same category — try profiles with different activity levels or sleep patterns to see different product bundles.

How health state categorization works

The core idea behind the smart cart is mapping raw health metrics to a high-level health state that drives product selection. There are two data sources:

  1. Scores — Sahha generates numeric scores on a 0–1 scale for dimensions like activity, readiness, sleep, wellbeing, and mental_wellbeing. Each score includes factor breakdowns with names, values, and states (e.g. low, medium, high).

  2. Archetypes — Weekly behavioral patterns that Sahha computes from sustained behavior over time. For example, activity_level: moderately_active tells you this person consistently exercises, even if today’s score dipped because they rested.

The categorization strategy prioritizes archetypes over raw scores. An established active lifestyle should not shift someone into recovery mode just because they had one low-readiness day. The decision tree:

archetype = activity_level
activity  = avg score for "activity"
readiness = avg score for "readiness"
sleep     = any factor with name containing "sleep" or "circadian"

├─ archetype in (moderately_active, highly_active)
│  AND activity ≥ 0.7
│  → high-training

├─ any sleep factor has state "low" / "minimal"
│  OR sleep factor score < 0.4
│  → sleep-support

├─ readiness < 0.78
│  → recovery

├─ readiness ≥ 0.78 AND activity ≥ 0.78
│  → high-training

└─ default
   → recovery

This gives you three health states — recovery, high-training, and sleep-support — each of which maps to a different product selection.

Query health data

Fetch the latest scores and archetypes for a given user. There are two important things to know:

  1. Scores include a type (e.g. activity, sleep, readiness), a numeric value, and factor breakdowns — extract these fields rather than passing raw JSON to the categorization logic
  2. Archetypes need a separate, dedicated query because they are less frequent than score events and get buried in a generic time-ordered query. Deduplicate by name so you only keep the latest value per archetype
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

// Fetch recent score events
async function getRecentScores(externalId: string) {
  const yesterday = new Date(
    Date.now() - 24 * 60 * 60 * 1000
  ).toISOString();

  const { data: events, error } = await supabase
    .from("sahha_events")
    .select("*")
    .eq("external_id", externalId)
    .eq("event_type", "ScoreCreatedIntegrationEvent")
    .gte("created_at", yesterday)
    .order("created_at", { ascending: false })
    .limit(50);

  if (error) throw error;
  return events;
}

// Fetch archetypes — deduplicated by name
async function getRecentArchetypes(externalId: string) {
  const { data: events, error } = await supabase
    .from("sahha_events")
    .select("*")
    .eq("external_id", externalId)
    .eq("event_type", "ArchetypeCreatedIntegrationEvent")
    .order("created_at", { ascending: false })
    .limit(30);

  if (error) throw error;

  // Keep only the latest event per archetype name
  const seen = new Set<string>();
  return events.filter((e) => {
    const name = e.payload?.name;
    if (name && !seen.has(name)) {
      seen.add(name);
      return true;
    }
    return false;
  });
}
// ─── convex/queries.ts ──────────────────────────────────
import { internalQuery } from "./_generated/server";
import { v } from "convex/values";

// Fetch recent events for a user (scores, biomarkers, etc.)
export const getByExternalId = internalQuery({
  args: {
    externalId: v.string(),
    limit: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const limit = args.limit ?? 50;
    const events = await ctx.db
      .query("healthEvents")
      .withIndex("by_user_type", (q) =>
        q.eq("externalId", args.externalId)
      )
      .order("desc")
      .take(limit);

    return events.map((e) => ({
      eventType: e.eventType,
      payload: e.payload,
      receivedAt: e.receivedAt,
    }));
  },
});

// Fetch archetypes — deduplicated by name
export const getArchetypesByExternalId = internalQuery({
  args: { externalId: v.string() },
  handler: async (ctx, args) => {
    const events = await ctx.db
      .query("healthEvents")
      .withIndex("by_user_type", (q) =>
        q
          .eq("externalId", args.externalId)
          .eq("eventType", "ArchetypeCreatedIntegrationEvent")
      )
      .order("desc")
      .take(30);

    // Keep only the latest per archetype name
    const seen = new Set<string>();
    const unique = [];
    for (const e of events) {
      const name = (e.payload as Record<string, unknown>)
        ?.name as string;
      if (name && !seen.has(name)) {
        seen.add(name);
        unique.push({
          eventType: e.eventType,
          payload: e.payload,
        });
      }
    }
    return unique;
  },
});

// Note: Both queries use internalQuery — they can only be called
// from other Convex server-side functions (like the HTTP action
// in the next step), not from the client.
import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

// Fetch recent score events
async function getRecentScores(externalId: string) {
  const yesterday = new Date(
    Date.now() - 24 * 60 * 60 * 1000
  ).toISOString();

  const snapshot = await db
    .collection("sahha_events")
    .doc(externalId)
    .collection("ScoreCreatedIntegrationEvent")
    .where("createdAtUtc", ">=", yesterday)
    .orderBy("createdAtUtc", "desc")
    .limit(50)
    .get();

  return snapshot.docs.map((doc) => doc.data());
}

// Fetch archetypes — deduplicated by name
async function getRecentArchetypes(externalId: string) {
  const snapshot = await db
    .collection("sahha_events")
    .doc(externalId)
    .collection("ArchetypeCreatedIntegrationEvent")
    .orderBy("createdAtUtc", "desc")
    .limit(30)
    .get();

  const events = snapshot.docs.map((doc) => doc.data());

  // Keep only the latest event per archetype name
  const seen = new Set<string>();
  return events.filter((e) => {
    const name = e.payload?.name;
    if (name && !seen.has(name)) {
      seen.add(name);
      return true;
    }
    return false;
  });
}
Archetypes like “moderately active” or “highly regular sleeper” change slowly — often weekly. Fetching them separately with deduplication ensures you always have the user’s current archetypes regardless of how many score events have been recorded since.

Categorize health state

The categorization logic is the same regardless of which database you use. Add these types and helper functions to your endpoint file:

// ─── Types ───────────────────────────────────────────────────────

interface ScoreData {
  type: string;
  score: number;
  state: string;
  factors: Array<{ name: string; score: number; state: string }>;
}

interface ArchetypeData {
  name: string;
  value: string;
  periodicity: string;
}

type HealthStateId = "recovery" | "high-training" | "sleep-support";

// ─── Score Helpers ───────────────────────────────────────────────

/** Average score for a given type across all events */
function avgScoreByType(
  scores: ScoreData[],
  type: string
): number | null {
  const matches = scores.filter((s) => s.type === type);
  if (matches.length === 0) return null;
  return matches.reduce((sum, s) => sum + s.score, 0) / matches.length;
}

/** Latest score per type (deduped, most recent first) */
function getLatestScores(scores: ScoreData[]) {
  const seen = new Set<string>();
  const latest: ScoreData[] = [];
  for (const s of scores) {
    if (!seen.has(s.type)) {
      seen.add(s.type);
      latest.push(s);
    }
  }
  return latest;
}

/**
 * Categorize health state from scores + archetypes.
 *
 * Archetypes (weekly behavioral patterns) override raw score dips
 * when they indicate an established lifestyle.
 */
function categorizeHealthState(
  scores: ScoreData[],
  archetypes: ArchetypeData[] = []
): HealthStateId {
  const avgReadiness = avgScoreByType(scores, "readiness");
  const avgActivity = avgScoreByType(scores, "activity");

  // Check for active archetype
  const activityArchetype = archetypes.find(
    (a) => a.name === "activity_level"
  );
  const isActiveArchetype =
    activityArchetype != null &&
    (activityArchetype.value === "moderately_active" ||
      activityArchetype.value === "highly_active");

  // Active lifestyle + good activity → high-training
  if (isActiveArchetype && avgActivity !== null && avgActivity >= 0.7) {
    return "high-training";
  }

  // Low sleep factors → sleep-support
  const hasLowSleepFactors = scores.some((s) =>
    s.factors.some(
      (f) =>
        (f.name.includes("sleep") || f.name.includes("circadian")) &&
        (f.state === "low" || f.state === "minimal" || f.score < 0.4)
    )
  );

  if (hasLowSleepFactors) {
    return "sleep-support";
  }

  // Low readiness → recovery
  if (avgReadiness !== null && avgReadiness < 0.78) {
    return "recovery";
  }

  // High readiness + high activity → high-training
  if (
    avgReadiness !== null &&
    avgActivity !== null &&
    avgReadiness >= 0.78 &&
    avgActivity >= 0.78
  ) {
    return "high-training";
  }

  return "recovery";
}
Tune these thresholds: The values here (readiness < 0.78, activity ≥ 0.7) are starting points. A fitness brand might lower the high-training threshold. A recovery-focused brand might be more aggressive about triggering recovery mode. Adjust based on your product range and customer base.
Going beyond three states: This guide maps all sleep issues to one sleep-support state, but Sahha’s archetypes distinguish why someone sleeps poorly. An irregular sleeper (sleep_regularity) needs different products than someone with poor sleep quality (sleep_quality) or short duration (sleep_duration). Use the same categorization pattern with more branches to target products at the cause, not just the symptom. See the full list of available signals in the Data Dictionary.
Sahha Summary
activity 0.41 readiness 0.58 sleep 0.72 | archetype sedentary

This week's cart is designed for recovery. Your readiness score is low and activity has dropped — we've selected anti-inflammatory foods, collagen, and gentle nutrition to support your body's repair cycle.

Bone Broth
Bone Broth
MEAL
$32.99
Turmeric Latte Mix
Turmeric Latte Mix
DRINK
$18.99
Magnesium Glycinate
Magnesium Glycinate
SUPPLEMENT
$21.99
Collagen Peptides
Collagen Peptides
SUPPLEMENT
$29.99
Built Recovery
$103.96 Total
Confirm Weekly Cart
Sahha Summary
activity 0.88 readiness 0.82 sleep 0.79 | archetype moderately_active

This week's cart is designed for high-training performance. Your activity archetype is moderately active and your activity score is high — we've loaded energy-dense foods, electrolytes, and performance supplements.

Electrolyte Mix
Electrolyte Mix
DRINK
$24.99
Trail Mix Energy Bars
Trail Mix Energy Bars
SNACK
$27.99
High-Protein Meal Kit
High-Protein Meal Kit
MEAL
$45.99
BCAA Capsules
BCAA Capsules
SUPPLEMENT
$26.99
Built High-Training
$125.96 Total
Confirm Weekly Cart
Sahha Summary
activity 0.63 readiness 0.71 sleep 0.34 | archetype lightly_active

This week's cart is designed for sleep support. Your sleep score is low with poor circadian alignment — we've picked calming teas, melatonin, and light evening meals to help restore your rhythm.

Chamomile Sleep Blend
Chamomile Sleep Blend
DRINK
$14.99
Melatonin Gummies
Melatonin Gummies
SUPPLEMENT
$18.99
Sleep-Friendly Dinner Kit
Sleep-Friendly Dinner Kit
MEAL
$34.99
Tart Cherry Concentrate
Tart Cherry Concentrate
DRINK
$21.99
Built Sleep-Support
$90.96 Total
Confirm Weekly Cart
Generated Smart Cart

Build the smart cart endpoint

Combine the queries and categorization logic into a single POST /smart-cart endpoint that accepts an externalId and returns the categorized health state with scores and archetypes.

// ─── supabase/functions/smart-cart/index.ts ─────────────
import { createClient } from "@supabase/supabase-js";

const supabase = createClient(
  Deno.env.get("SUPABASE_URL")!,
  Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);

Deno.serve(async (req) => {
  // CORS preflight
  if (req.method === "OPTIONS") {
    return new Response(null, {
      status: 204,
      headers: {
        "Access-Control-Allow-Origin": "*",
        "Access-Control-Allow-Methods": "POST, OPTIONS",
        "Access-Control-Allow-Headers": "Content-Type",
      },
    });
  }

  if (req.method !== "POST") {
    return new Response("Method not allowed", { status: 405 });
  }

  const corsHeaders = {
    "Access-Control-Allow-Origin": "*",
    "Access-Control-Allow-Methods": "POST, OPTIONS",
    "Access-Control-Allow-Headers": "Content-Type",
  };

  try {
    const { externalId } = await req.json();
    if (!externalId) {
      return new Response(
        JSON.stringify({ error: "externalId is required" }),
        {
          status: 400,
          headers: { "Content-Type": "application/json", ...corsHeaders },
        }
      );
    }

    // 1. Query scores
    const yesterday = new Date(
      Date.now() - 24 * 60 * 60 * 1000
    ).toISOString();

    const { data: scoreEvents } = await supabase
      .from("sahha_events")
      .select("*")
      .eq("external_id", externalId)
      .eq("event_type", "ScoreCreatedIntegrationEvent")
      .gte("created_at", yesterday)
      .order("created_at", { ascending: false })
      .limit(50);

    // 2. Query archetypes (deduplicated)
    const { data: allArchetypes } = await supabase
      .from("sahha_events")
      .select("*")
      .eq("external_id", externalId)
      .eq("event_type", "ArchetypeCreatedIntegrationEvent")
      .order("created_at", { ascending: false })
      .limit(30);

    const seen = new Set<string>();
    const archetypeEvents = (allArchetypes ?? []).filter((e: any) => {
      const name = e.payload?.name;
      if (name && !seen.has(name)) {
        seen.add(name);
        return true;
      }
      return false;
    });

    // 3. Extract scores
    const scores = (scoreEvents ?? [])
      .map((e: any) => {
        const p = e.payload;
        return {
          type: (p?.type as string) ?? "unknown",
          score: (p?.score as number) ?? 0,
          state: (p?.state as string) ?? "unknown",
          factors: Array.isArray(p?.factors)
            ? p.factors.map((f: any) => ({
                name: (f.name as string) ?? "",
                score: (f.score as number) ?? 0,
                state: (f.state as string) ?? "",
              }))
            : [],
        };
      });

    // 4. Extract archetypes
    const archetypes = archetypeEvents.map((e: any) => {
      const p = e.payload;
      return {
        name: (p?.name as string) ?? "",
        value: (p?.value as string) ?? "",
        periodicity: (p?.periodicity as string) ?? "",
      };
    });

    // 5. Categorize health state
    const healthState = categorizeHealthState(scores, archetypes);

    // 6. Query products for this health state
    const { data: products, error: productsError } = await supabase
      .from("products")
      .select("*")
      .eq("active", true)
      .contains("for_states", [healthState])
      .order("priority", { ascending: true })
      .limit(4);

    if (productsError) throw productsError;

    // 7. Respond with health state + products
    return new Response(
      JSON.stringify({
        externalId,
        healthState,
        scores: getLatestScores(scores),
        archetypes,
        products: (products ?? []).map((p: any) => ({
          productId: p.id,
          name: p.name,
          description: p.description,
          category: p.category,
          price: p.price,
          imageUrl: p.image_url,
          tags: p.tags,
        })),
      }),
      {
        status: 200,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  } catch (err) {
    return new Response(
      JSON.stringify({ error: "Internal server error" }),
      {
        status: 500,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  }
});

// --- Helper functions (types + categorization) go here —
//     same as the section above ---
//
// Deploy: npx supabase functions deploy smart-cart --no-verify-jwt
// ─── convex/smartCart.ts ────────────────────────────────
import { httpAction } from "./_generated/server";
import { internal } from "./_generated/api";

const corsHeaders = {
  "Access-Control-Allow-Origin": "*",
  "Access-Control-Allow-Methods": "POST, OPTIONS",
  "Access-Control-Allow-Headers": "Content-Type",
};

export const generateCart = httpAction(async (ctx, request) => {
  if (request.method === "OPTIONS") {
    return new Response(null, { status: 204, headers: corsHeaders });
  }

  try {
    const { externalId } = await request.json();
    if (!externalId) {
      return new Response(
        JSON.stringify({ error: "externalId is required" }),
        { status: 400, headers: { "Content-Type": "application/json", ...corsHeaders } }
      );
    }

    // 1. Query health data (both in parallel)
    const [events, archetypeEvents] = await Promise.all([
      ctx.runQuery(internal.queries.getByExternalId, { externalId, limit: 50 }),
      ctx.runQuery(internal.queries.getArchetypesByExternalId, { externalId }),
    ]);

    if (!events?.length && !archetypeEvents?.length) {
      return new Response(
        JSON.stringify({ error: "No health data found for this profile" }),
        { status: 404, headers: { "Content-Type": "application/json", ...corsHeaders } }
      );
    }

    // 2. Extract scores from ScoreCreatedIntegrationEvent payloads
    const scores = (events ?? [])
      .filter((e: { eventType: string }) =>
        e.eventType === "ScoreCreatedIntegrationEvent"
      )
      .map((e: { payload: unknown }) => {
        const p = e.payload as Record<string, unknown>;
        return {
          type: (p?.type as string) ?? "unknown",
          score: (p?.score as number) ?? 0,
          state: (p?.state as string) ?? "unknown",
          factors: Array.isArray(p?.factors)
            ? (p.factors as Array<Record<string, unknown>>).map((f) => ({
                name: (f.name as string) ?? "",
                score: (f.score as number) ?? 0,
                state: (f.state as string) ?? "",
              }))
            : [],
        };
      });

    // 3. Extract archetypes
    const archetypes = (archetypeEvents ?? []).map(
      (e: { payload: unknown }) => {
        const p = e.payload as Record<string, unknown>;
        return {
          name: (p?.name as string) ?? "",
          value: (p?.value as string) ?? "",
          periodicity: (p?.periodicity as string) ?? "",
        };
      }
    );

    // 4. Categorize health state
    const healthState = categorizeHealthState(scores, archetypes);

    // 5. Query products for this health state
    const products = await ctx.runQuery(
      internal.queries.getProductsForState,
      { stateId: healthState }
    );

    // 6. Return structured data
    return new Response(
      JSON.stringify({
        externalId,
        healthState,
        scores: getLatestScores(scores),
        archetypes,
        products: products.map((p) => ({
          productId: p.productId,
          name: p.name,
          description: p.description,
          category: p.category,
          price: p.price,
          imageUrl: p.imageUrl,
          tags: p.tags,
        })),
      }),
      {
        status: 200,
        headers: { "Content-Type": "application/json", ...corsHeaders },
      }
    );
  } catch (err: unknown) {
    const message = err instanceof Error ? err.message : String(err);
    console.error("Smart cart error:", message);
    return new Response(
      JSON.stringify({ error: "Internal server error" }),
      { status: 500, headers: { "Content-Type": "application/json", ...corsHeaders } }
    );
  }
});

// --- Helper functions (types + categorization) go here —
//     same as the section above ---
// ─── convex/http.ts — register the route ────────────────
// Important: Never return HTTP status 502 from a Convex
// httpAction — Convex intercepts 502 responses and strips
// CORS headers. Use 500 for server errors instead.
import { httpRouter } from "convex/server";
import { handleWebhook } from "./sahhaWebhook";
import { generateCart } from "./smartCart";

const http = httpRouter();

// Sahha Webhook (from the data streaming guide)
http.route({ path: "/sahha-webhook", method: "POST", handler: handleWebhook });
http.route({ path: "/sahha-webhook", method: "OPTIONS", handler: handleWebhook });

// Smart Cart endpoint
http.route({ path: "/smart-cart", method: "POST", handler: generateCart });
http.route({ path: "/smart-cart", method: "OPTIONS", handler: generateCart });

export default http;
//
// Deploy: npx convex dev
// ─── functions/src/smartCart.ts ──────────────────────────
import { onRequest } from "firebase-functions/v2/https";
import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export const smartCart = onRequest(async (req, res) => {
  // CORS
  res.set("Access-Control-Allow-Origin", "*");
  res.set("Access-Control-Allow-Methods", "POST, OPTIONS");
  res.set("Access-Control-Allow-Headers", "Content-Type");

  if (req.method === "OPTIONS") {
    res.status(204).send("");
    return;
  }

  if (req.method !== "POST") {
    res.status(405).send("Method not allowed");
    return;
  }

  try {
    const { externalId } = req.body;
    if (!externalId) {
      res.status(400).json({ error: "externalId is required" });
      return;
    }

    // 1. Query scores
    const yesterday = new Date(
      Date.now() - 24 * 60 * 60 * 1000
    ).toISOString();

    const scoreSnapshot = await db
      .collection("sahha_events")
      .doc(externalId)
      .collection("ScoreCreatedIntegrationEvent")
      .where("createdAtUtc", ">=", yesterday)
      .orderBy("createdAtUtc", "desc")
      .limit(50)
      .get();

    const scoreEvents = scoreSnapshot.docs.map((doc) =>
      doc.data()
    );

    // 2. Query archetypes (deduplicated)
    const archSnapshot = await db
      .collection("sahha_events")
      .doc(externalId)
      .collection("ArchetypeCreatedIntegrationEvent")
      .orderBy("createdAtUtc", "desc")
      .limit(30)
      .get();

    const allArchetypes = archSnapshot.docs.map((doc) =>
      doc.data()
    );

    const seen = new Set<string>();
    const archetypeEvents = allArchetypes.filter((e) => {
      const name = e.payload?.name;
      if (name && !seen.has(name)) {
        seen.add(name);
        return true;
      }
      return false;
    });

    // 3. Extract scores
    const scores = scoreEvents.map((e: any) => {
      const p = e.payload ?? e;
      return {
        type: (p?.type as string) ?? "unknown",
        score: (p?.score as number) ?? 0,
        state: (p?.state as string) ?? "unknown",
        factors: Array.isArray(p?.factors)
          ? p.factors.map((f: any) => ({
              name: (f.name as string) ?? "",
              score: (f.score as number) ?? 0,
              state: (f.state as string) ?? "",
            }))
          : [],
      };
    });

    // 4. Extract archetypes
    const archetypes = archetypeEvents.map((e: any) => {
      const p = e.payload ?? e;
      return {
        name: (p?.name as string) ?? "",
        value: (p?.value as string) ?? "",
        periodicity: (p?.periodicity as string) ?? "",
      };
    });

    // 5. Categorize health state
    const healthState = categorizeHealthState(scores, archetypes);

    // 6. Query products for this health state
    const productsSnapshot = await db
      .collection("products")
      .where("active", "==", true)
      .where("forStates", "array-contains", healthState)
      .orderBy("priority", "asc")
      .limit(4)
      .get();

    const products = productsSnapshot.docs.map((doc) => {
      const d = doc.data();
      return {
        productId: doc.id,
        name: d.name,
        description: d.description,
        category: d.category,
        price: d.price,
        imageUrl: d.imageUrl,
        tags: d.tags,
      };
    });

    // 7. Respond with health state + products
    res.status(200).json({
      externalId,
      healthState,
      scores: getLatestScores(scores),
      archetypes,
      products,
    });
  } catch (err) {
    console.error("Smart cart error:", err);
    res.status(500).json({ error: "Internal server error" });
  }
});

// --- Helper functions (types + categorization) go here —
//     same as the section above ---
//
// Deploy: firebase deploy --only functions

Test your endpoint with curl:

curl -X POST https://YOUR_ENDPOINT_URL/smart-cart \
  -H "Content-Type: application/json" \
  -d '{"externalId": "your-sahha-profile-id"}'

You should see a response like this:

POST /smart-cart 200
json
{
  "externalId": "your-sahha-profile-id",
  "healthState": "high-training",
  "scores": [
    {
      "type": "activity",
      "score": 0.82,
      "state": "high",
      "factors": [
        { "name": "steps", "score": 0.9, "state": "high" },
        { "name": "active_hours", "score": 0.74, "state": "medium" }
      ]
    },
    {
      "type": "readiness",
      "score": 0.79,
      "state": "medium",
      "factors": []
    }
  ],
  "archetypes": [
    {
      "name": "activity_level",
      "value": "moderately_active",
      "periodicity": "weekly"
    }
  ]
}

This endpoint generates a fresh cart each time. If you want to scan an existing cart and suggest item-level substitutions based on the current health state, see Smart Swaps in the bonus section below.

Map health states to products

The last piece is a product catalog stored in your database, where each product declares which health states it belongs to. When the API returns "high-training", the cart query filters to products tagged for that state.

Each product has these fields:

interface Product {
  id: string;
  name: string;
  description: string;
  category: "SUPPLEMENT" | "SNACK" | "MEAL" | "DRINK";
  price: number;              // in cents
  tags: string[];
  forStates: HealthStateId[]; // which health states include this product
  priority: number;           // lower = shown first
  scoreMapping?: {            // optional: per-product score targeting
    scoreType: string;
    minValue: number;
  };
  archetypeMatch?: {          // optional: per-product archetype targeting
    name: string;
    values: string[];
  };
  imageUrl?: string;
  active: boolean;
}

The forStates array is the key field — it controls which products appear for each health state. A product can belong to multiple states (e.g. magnesium supplements for both recovery and sleep-support). The priority field controls sort order when the cart has limited slots. The optional scoreMapping and archetypeMatch fields enable richer per-product targeting beyond the 3-state model — for example, only showing a specific electrolyte mix when activity score exceeds 0.85.

Electrolyte Mix
Electrolyte Mix
DRINK high-training
$24.99
Mapping Configuration
forStates ["high-training"]
category "DRINK"
priority 1
scoreMapping { scoreType: "activity", minValue: 0.7 }
archetypeMatch { name: "activity_level", values: ["moderately_active", "highly_active"] }
tags ["hydration", "electrolytes", "performance"]
Magnesium Glycinate
Magnesium Glycinate
SUPPLEMENT recovery sleep-support
$21.99
Mapping Configuration
forStates ["recovery", "sleep-support"]
category "SUPPLEMENT"
priority 2
scoreMapping null
archetypeMatch null
tags ["magnesium", "recovery", "sleep"]
Chamomile Sleep Blend
Chamomile Sleep Blend
DRINK sleep-support
$14.99
Mapping Configuration
forStates ["sleep-support"]
category "DRINK"
priority 1
scoreMapping { scoreType: "sleep", minValue: 0 }
archetypeMatch null
tags ["sleep", "tea", "calming"]
Whey Protein Isolate
Whey Protein Isolate
SUPPLEMENT high-training
$34.99
Mapping Configuration
forStates ["high-training"]
category "SUPPLEMENT"
priority 3
scoreMapping { scoreType: "activity", minValue: 0.8 }
archetypeMatch { name: "activity_level", values: ["moderately_active", "highly_active"] }
tags ["protein", "muscle", "performance"]
Bone Broth
Bone Broth
MEAL recovery
$32.99
Mapping Configuration
forStates ["recovery"]
category "MEAL"
priority 1
scoreMapping null
archetypeMatch null
tags ["collagen", "anti-inflammatory", "gut-health"]
1 / 5
Product Mapping Configuration
create table products (
  id text primary key,
  name text not null,
  description text not null,
  category text not null
    check (category in ('SUPPLEMENT', 'SNACK', 'MEAL', 'DRINK')),
  price integer not null,
  tags text[] default '{}',
  for_states text[] not null,
  priority integer not null default 10,
  score_mapping jsonb,
  archetype_match jsonb,
  image_url text,
  active boolean not null default true
);

-- Seed (3 of 15 shown — full seed in starter project)
insert into products
  (id, name, description, category, price,
   tags, for_states, priority, image_url, active)
values
  ('bone-broth', 'Bone Broth (Pack of 6)',
   'Collagen-rich. Slow-simmered 24hrs.',
   'MEAL', 3299,
   '{"collagen","anti-inflammatory","recovery"}',
   '{"recovery"}', 1,
   'https://images.unsplash.com/photo-1547592166-23ac45744acd?w=200&h=200&fit=crop',
   true);

insert into products
  (id, name, description, category, price,
   tags, for_states, priority, image_url, active)
values
  ('electrolyte-mix', 'Electrolyte Mix (30 sticks)',
   'Zero sugar. Rapid rehydration.',
   'DRINK', 2499,
   '{"hydration","electrolytes","performance"}',
   '{"high-training"}', 1,
   'https://images.unsplash.com/photo-1622597467836-f3285f2131b8?w=200&h=200&fit=crop',
   true);

insert into products
  (id, name, description, category, price,
   tags, for_states, priority, image_url, active)
values
  ('chamomile-sleep-blend',
   'Chamomile Sleep Blend (20 bags)',
   'Chamomile, valerian, passionflower.',
   'DRINK', 1499,
   '{"sleep","calming","herbal"}',
   '{"sleep-support"}', 1,
   'https://images.unsplash.com/photo-1563911892437-1feda0179e1b?w=200&h=200&fit=crop',
   true);
// ─── Query products for a health state ──────────────────
async function buildCartForState(
  stateId: HealthStateId,
  maxItems = 4
) {
  const { data: products, error } = await supabase
    .from("products")
    .select("*")
    .eq("active", true)
    .contains("for_states", [stateId])
    .order("priority", { ascending: true })
    .limit(maxItems);

  if (error) throw error;
  return products;
}
// ─── convex/schema.ts — products table ──────────────────
products: defineTable({
  productId: v.string(),
  name: v.string(),
  description: v.string(),
  category: v.union(
    v.literal("SUPPLEMENT"),
    v.literal("SNACK"),
    v.literal("MEAL"),
    v.literal("DRINK")
  ),
  price: v.number(),
  tags: v.array(v.string()),
  forStates: v.array(v.string()),
  priority: v.number(),
  scoreMapping: v.optional(
    v.object({ scoreType: v.string(), minValue: v.number() })
  ),
  archetypeMatch: v.optional(
    v.object({ name: v.string(), values: v.array(v.string()) })
  ),
  imageUrl: v.optional(v.string()),
  active: v.boolean(),
}).index("by_active", ["active"]),
// ─── convex/seedProducts.ts (3 of 15 — full seed in starter) ─
import { internalMutation } from "./_generated/server";

export const seed = internalMutation(async (ctx) => {
  const products = [
    {
      productId: "bone-broth",
      name: "Bone Broth (Pack of 6)",
      description: "Collagen-rich. Slow-simmered 24hrs.",
      category: "MEAL" as const, price: 3299,
      tags: ["collagen", "anti-inflammatory", "recovery"],
      forStates: ["recovery"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1547592166-23ac45744acd?w=200&h=200&fit=crop",
      active: true,
    },
    {
      productId: "electrolyte-mix",
      name: "Electrolyte Mix (30 sticks)",
      description: "Zero sugar. Rapid rehydration.",
      category: "DRINK" as const, price: 2499,
      tags: ["hydration", "electrolytes", "performance"],
      forStates: ["high-training"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1622597467836-f3285f2131b8?w=200&h=200&fit=crop",
      active: true,
    },
    {
      productId: "chamomile-sleep-blend",
      name: "Chamomile Sleep Blend (20 bags)",
      description: "Chamomile, valerian, passionflower.",
      category: "DRINK" as const, price: 1499,
      tags: ["sleep", "calming", "herbal"],
      forStates: ["sleep-support"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1563911892437-1feda0179e1b?w=200&h=200&fit=crop",
      active: true,
    },
    // ... 12 more across all states and categories
  ];

  for (const p of products) {
    await ctx.db.insert("products", p);
  }
});
// ─── convex/queries.ts — query products for a health state ──
export const getProductsForState = internalQuery({
  args: {
    stateId: v.string(),
    maxItems: v.optional(v.number()),
  },
  handler: async (ctx, args) => {
    const limit = args.maxItems ?? 4;
    const all = await ctx.db
      .query("products")
      .withIndex("by_active", (q) => q.eq("active", true))
      .collect();

    return all
      .filter((p) => p.forStates.includes(args.stateId))
      .sort((a, b) => a.priority - b.priority)
      .slice(0, limit);
  },
});
// ─── products collection (3 of 15 — full seed in starter) ──
import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

async function seedProducts() {
  const products = [
    {
      id: "bone-broth",
      name: "Bone Broth (Pack of 6)",
      description: "Collagen-rich. Slow-simmered 24hrs.",
      category: "MEAL", price: 3299,
      tags: ["collagen", "anti-inflammatory", "recovery"],
      forStates: ["recovery"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1547592166-23ac45744acd?w=200&h=200&fit=crop",
      active: true,
    },
    {
      id: "electrolyte-mix",
      name: "Electrolyte Mix (30 sticks)",
      description: "Zero sugar. Rapid rehydration.",
      category: "DRINK", price: 2499,
      tags: ["hydration", "electrolytes", "performance"],
      forStates: ["high-training"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1622597467836-f3285f2131b8?w=200&h=200&fit=crop",
      active: true,
    },
    {
      id: "chamomile-sleep-blend",
      name: "Chamomile Sleep Blend (20 bags)",
      description: "Chamomile, valerian, passionflower.",
      category: "DRINK", price: 1499,
      tags: ["sleep", "calming", "herbal"],
      forStates: ["sleep-support"], priority: 1,
      imageUrl: "https://images.unsplash.com/photo-1563911892437-1feda0179e1b?w=200&h=200&fit=crop",
      active: true,
    },
    // ... 12 more across all states and categories
  ];

  const batch = db.batch();
  for (const product of products) {
    const { id, ...data } = product;
    batch.set(db.collection("products").doc(id), data);
  }
  await batch.commit();
}
// ─── Query products for a health state ──────────────────
async function buildCartForState(
  stateId: HealthStateId,
  maxItems = 4
) {
  const snapshot = await db
    .collection("products")
    .where("active", "==", true)
    .where("forStates", "array-contains", stateId)
    .orderBy("priority", "asc")
    .limit(maxItems)
    .get();

  return snapshot.docs.map((doc) => ({
    id: doc.id,
    ...doc.data(),
  }));
}
Seed your products before testing the endpoint. For Supabase, run the SQL inserts in the SQL Editor. For Convex, run npx convex run seedProducts:seed from your terminal. For Firebase, call the seedProducts() function as a one-off script or from a Cloud Function. The starter project includes the full 15-product catalog — the 3 shown above are a subset.
Cart composition is your call: This example picks 4 items sorted by priority. A meal kit company might want 7. A supplement brand might want 3. Consider balancing across categories and adding price constraints.
Your decision point: The mapping between health states and products depends on your catalog, your customers, and the health claims your brand can make. The seed data here is illustrative — replace it with your own SKUs and mapping logic.

Build the cart message

The API returns structured data — health state, scores, products. Before showing it to users, build a human-readable message that explains why this cart was generated.

const STATE_LABELS: Record<HealthStateId, string> = {
  recovery: "recovery",
  "high-training": "high-training performance",
  "sleep-support": "sleep support",
};

interface CartMessage {
  headline: string;
  body: string;
  products: Array<{
    name: string;
    category: string;
    price: number;
  }>;
}

function buildCartMessage(
  healthState: HealthStateId,
  scores: ScoreData[],
  cartItems: Product[]
): CartMessage {
  const label = STATE_LABELS[healthState];

  // Pick the most relevant score factor as the reason
  const topFactor = scores
    .flatMap((s) => s.factors)
    .filter((f) => f.state === "low" || f.state === "high")
    .sort((a, b) => Math.abs(b.score - 0.5) - Math.abs(a.score - 0.5))[0];

  const reason = topFactor
    ? ` Your ${topFactor.name.replace(/_/g, " ")} score is ${topFactor.state}.`
    : "";

  // Group products by category
  const byCategory = cartItems.reduce(
    (acc, item) => {
      const cat = item.category.toLowerCase();
      if (!acc[cat]) acc[cat] = [];
      acc[cat].push(item.name);
      return acc;
    },
    {} as Record<string, string[]>
  );

  const categoryLines = Object.entries(byCategory)
    .map(([cat, names]) => `**${cat}:** ${names.join(", ")}`)
    .join("\n");

  return {
    headline: `This week's cart is designed for ${label}`,
    body: `Based on your latest health data, we've built a ${label} cart.${reason}\n\n${categoryLines}`,
    products: cartItems.map((p) => ({
      name: p.name,
      category: p.category,
      price: p.price,
    })),
  };
}

Example output for a recovery state:

headline: "This week's cart is designed for recovery"
body: "Based on your latest health data, we've built a recovery cart.
       Your active hours score is low.

       **meal:** Bone Broth (Pack of 6)
       **drink:** Golden Turmeric Latte Mix
       **supplement:** Magnesium Glycinate (60 caps), Collagen Peptides (Unflavored)"

Store generated carts

Persisting generated carts lets you show “this week’s cart” without re-computing, track user actions over time, and pre-generate carts on a schedule.

Carts table schema

-- ─── Supabase: carts table ─────────────────────────────
create table carts (
  cart_id uuid primary key default gen_random_uuid(),
  external_id text not null,
  health_state text not null,
  generated_at timestamptz not null default now(),
  items jsonb not null,
  total_price integer not null,
  status text not null default 'draft'
    check (status in ('draft', 'accepted', 'modified', 'purchased')),

  constraint fk_external_id_exists check (external_id <> '')
);

create index idx_carts_external_id on carts (external_id, generated_at desc);
// ─── convex/schema.ts — carts table ────────────────────
carts: defineTable({
  externalId: v.string(),
  healthState: v.string(),
  generatedAt: v.number(),
  items: v.array(
    v.object({
      productId: v.string(),
      name: v.string(),
      price: v.number(),
      quantity: v.number(),
    })
  ),
  totalPrice: v.number(),
  status: v.union(
    v.literal("draft"),
    v.literal("accepted"),
    v.literal("modified"),
    v.literal("purchased")
  ),
})
  .index("by_external_id", ["externalId", "generatedAt"]),
// ─── Firestore: carts/{cartId} ─────────────────────────
interface CartDocument {
  externalId: string;
  healthState: string;
  generatedAt: FirebaseFirestore.Timestamp;
  items: Array<{
    productId: string;
    name: string;
    price: number;
    quantity: number;
  }>;
  totalPrice: number;
  status: "draft" | "accepted" | "modified" | "purchased";
}

Write the cart after generation

Update your smart cart endpoint to store the generated cart before returning. Here is the Convex example — the pattern is the same for Supabase and Firebase:

// Inside your smart cart endpoint, after building the cart:
const cartItems = products.map((p) => ({
  productId: p.productId,
  name: p.name,
  price: p.price,
  quantity: 1,
}));

const totalPrice = cartItems.reduce(
  (sum, item) => sum + item.price * item.quantity,
  0
);

const cartId = await ctx.runMutation(internal.carts.create, {
  externalId,
  healthState,
  generatedAt: Date.now(),
  items: cartItems,
  totalPrice,
  status: "draft",
});

// Include cartId in the response
return new Response(
  JSON.stringify({
    cartId,
    externalId,
    healthState,
    scores: getLatestScores(scores),
    archetypes,
    products: cartItems,
    totalPrice,
  }),
  { status: 200, headers: { "Content-Type": "application/json", ...corsHeaders } }
);

Track cart interactions

Cart interaction events tell you what users actually do with the generated carts — which items they keep, remove, or swap. This data feeds back into your product mapping decisions.

Cart events table

-- ─── Supabase: cart_events table ───────────────────────
create table cart_events (
  event_id uuid primary key default gen_random_uuid(),
  cart_id uuid not null references carts(cart_id),
  external_id text not null,
  action text not null
    check (action in ('accepted', 'removed', 'swapped', 'repurchased', 'quantity_changed')),
  product_id text,
  swapped_for_product_id text,
  created_at timestamptz not null default now(),
  metadata jsonb
);

create index idx_cart_events_cart on cart_events (cart_id, created_at);
create index idx_cart_events_user on cart_events (external_id, created_at desc);
// ─── convex/schema.ts — cartEvents table ───────────────
cartEvents: defineTable({
  cartId: v.id("carts"),
  externalId: v.string(),
  action: v.union(
    v.literal("accepted"),
    v.literal("removed"),
    v.literal("swapped"),
    v.literal("repurchased"),
    v.literal("quantity_changed")
  ),
  productId: v.optional(v.string()),
  swappedForProductId: v.optional(v.string()),
  createdAt: v.number(),
  metadata: v.optional(v.any()),
})
  .index("by_cart", ["cartId", "createdAt"])
  .index("by_user", ["externalId", "createdAt"]),
// ─── Firestore: cart_events/{eventId} ──────────────────
interface CartEventDocument {
  cartId: string;
  externalId: string;
  action: "accepted" | "removed" | "swapped" | "repurchased" | "quantity_changed";
  productId?: string;
  swappedForProductId?: string;
  createdAt: FirebaseFirestore.Timestamp;
  metadata?: Record<string, unknown>;
}

Logging functions

async function logCartAccepted(cartId: string, externalId: string) {
  await insertCartEvent({
    cartId,
    externalId,
    action: "accepted",
    createdAt: Date.now(),
  });
}

async function logItemRemoved(
  cartId: string,
  externalId: string,
  productId: string
) {
  await insertCartEvent({
    cartId,
    externalId,
    action: "removed",
    productId,
    createdAt: Date.now(),
  });
}

async function logItemSwapped(
  cartId: string,
  externalId: string,
  productId: string,
  swappedForProductId: string
) {
  await insertCartEvent({
    cartId,
    externalId,
    action: "swapped",
    productId,
    swappedForProductId,
    createdAt: Date.now(),
  });
}

Call these from your frontend when users interact with the cart — accept the whole cart, remove individual items, or apply swap suggestions.

Feedback loop: These interaction events are the data that lets you refine your product mappings over time. If users consistently remove a product from recovery carts, it’s a signal to adjust the mapping. This is the same webhook/event pattern from the data streaming prerequisite — applied to cart actions.

Bonus: Workflow triggers

The smart cart so far is pull-based — the user requests it and gets a snapshot. The real power comes when you layer on push-based triggers that react to health state changes automatically. Here are three patterns you can add on top of the existing endpoint.

Timing nudges

Instead of showing the cart whenever the user opens the app, trigger it at the moment when the data signals a transition. Compare the current health state to the last one you stored and fire a notification when it changes.

interface StateTransition {
  previous: HealthStateId | null;
  current: HealthStateId;
  changedAt: string;
}

function detectTransition(
  previous: HealthStateId | null,
  current: HealthStateId
): StateTransition | null {
  if (previous === current) return null;
  return {
    previous,
    current,
    changedAt: new Date().toISOString(),
  };
}

Store the last known health state per user in your database. Each time new webhook data arrives, run the smart cart categorization and compare the result to the stored state:

async function checkAndNudge(
  externalId: string,
  currentState: HealthStateId
) {
  // Read the last known state from your database
  const previous = await getStoredHealthState(externalId);

  const transition = detectTransition(previous, currentState);

  // Upsert current state
  await upsertHealthState(externalId, currentState);

  if (transition) {
    // Fire a push notification, email, or in-app message
    console.log(
      `State changed: ${transition.previous} → ${transition.current}`
    );
  }
}

When a transition is detected, fire a push notification or in-app message: “Your health state shifted from high-training to recovery — we updated your cart with anti-inflammatory foods and collagen.” The nudge arrives within minutes of the data changing, not the next time the user happens to check.

Run the comparison each time new webhook data arrives — either inside the webhook handler itself or as a scheduled job that polls every few hours.

Scheduled cart generation

Instead of building carts on-demand, pre-generate them on a schedule so the cart is ready when the user opens the app. This is the “pre-built cart” pattern — no loading spinner, no API call on the critical path.

-- ─── pg_cron: run every Monday at 6am UTC ──────────────
select cron.schedule(
  'weekly-cart-generation',
  '0 6 * * 1',
  $$select net.http_post(
    url := 'https://YOUR_PROJECT.supabase.co/functions/v1/generate-all-carts',
    headers := '{"Authorization": "Bearer YOUR_SERVICE_ROLE_KEY"}'::jsonb
  )$$
);
// ─── supabase/functions/generate-all-carts/index.ts ─────
Deno.serve(async () => {
  // Fetch all active profiles
  const { data: profiles } = await supabase
    .from("sahha_profiles")
    .select("external_id")
    .eq("active", true);

  for (const profile of profiles ?? []) {
    const scores = await getRecentScores(profile.external_id);
    const archetypes = await getRecentArchetypes(profile.external_id);
    const healthState = categorizeHealthState(scores, archetypes);
    const products = await buildCartForState(healthState);

    const items = products.map((p: any) => ({
      productId: p.id,
      name: p.name,
      price: p.price,
      quantity: 1,
    }));

    await supabase.from("carts").upsert({
      external_id: profile.external_id,
      health_state: healthState,
      items,
      total_price: items.reduce(
        (sum: number, i: any) => sum + i.price,
        0
      ),
      status: "draft",
      generated_at: new Date().toISOString(),
    }, { onConflict: "external_id" });
  }

  return new Response(
    JSON.stringify({ generated: profiles?.length ?? 0 }),
    { status: 200, headers: { "Content-Type": "application/json" } }
  );
});
// ─── convex/crons.ts ────────────────────────────────────
import { cronJobs } from "convex/server";
import { internal } from "./_generated/api";

const crons = cronJobs();

// Every Monday at 6:00 AM UTC
crons.weekly(
  "generate weekly carts",
  { dayOfWeek: "monday", hourUTC: 6, minuteUTC: 0 },
  internal.cartGeneration.generateAllCarts
);

export default crons;
// ─── convex/cartGeneration.ts ───────────────────────────
import { internalAction } from "./_generated/server";
import { internal } from "./_generated/api";

export const generateAllCarts = internalAction(async (ctx) => {
  const profiles = await ctx.runQuery(
    internal.queries.getActiveProfiles
  );

  for (const profile of profiles) {
    const [events, archetypeEvents] = await Promise.all([
      ctx.runQuery(internal.queries.getByExternalId, {
        externalId: profile.externalId,
        limit: 50,
      }),
      ctx.runQuery(internal.queries.getArchetypesByExternalId, {
        externalId: profile.externalId,
      }),
    ]);

    const scores = extractScores(events ?? []);
    const archetypes = extractArchetypes(archetypeEvents ?? []);
    const healthState = categorizeHealthState(scores, archetypes);

    const products = await ctx.runQuery(
      internal.queries.getProductsForState,
      { stateId: healthState }
    );

    const items = products.map((p) => ({
      productId: p.productId,
      name: p.name,
      price: p.price,
      quantity: 1,
    }));

    await ctx.runMutation(internal.carts.upsertWeeklyCart, {
      externalId: profile.externalId,
      healthState,
      items,
      totalPrice: items.reduce(
        (sum, i) => sum + i.price * i.quantity,
        0
      ),
    });
  }
});
// ─── Cloud Scheduler + Cloud Function ───────────────────
import { onSchedule } from "firebase-functions/v2/scheduler";
import { getFirestore } from "firebase-admin/firestore";

const db = getFirestore();

export const generateWeeklyCarts = onSchedule(
  "every monday 06:00",
  async () => {
    const profilesSnapshot = await db
      .collection("sahha_profiles")
      .where("active", "==", true)
      .get();

    for (const doc of profilesSnapshot.docs) {
      const externalId = doc.id;
      const scores = await getRecentScores(externalId);
      const archetypes = await getRecentArchetypes(externalId);
      const healthState = categorizeHealthState(
        extractScores(scores),
        extractArchetypes(archetypes)
      );

      const products = await buildCartForState(healthState);

      const items = products.map((p: any) => ({
        productId: p.id,
        name: p.name,
        price: p.price,
        quantity: 1,
      }));

      await db.collection("carts").doc(externalId).set({
        externalId,
        healthState,
        items,
        totalPrice: items.reduce(
          (sum: number, i: any) => sum + i.price,
          0
        ),
        status: "draft",
        generatedAt: new Date(),
      }, { merge: true });
    }
  }
);

This is the pre-built cart pattern — the cart is ready when the user opens the app. The scheduled function fetches all active profiles, runs categorization for each, and upserts into the carts table with status "draft". You can optionally compare the new health state to last week’s and flag transitions for notification.

Smart swaps

Rather than replacing the entire cart, scan an existing cart or order history and suggest item-level substitutions based on the current health state. The swap is category-aware — it replaces a snack with a snack, a drink with a drink — so the cart structure stays familiar while the contents adapt.

interface SwapSuggestion {
  originalId: string;
  originalName: string;
  swapId: string;
  swapName: string;
  reason: string;
}

function suggestSwaps(
  cartItemIds: string[],
  healthState: HealthStateId
): SwapSuggestion[] {
  const swaps: SwapSuggestion[] = [];

  for (const itemId of cartItemIds) {
    const item = PRODUCT_CATALOG.find((p) => p.id === itemId);
    if (!item) continue;

    // Skip items that already match the current health state
    if (item.forStates.includes(healthState)) continue;

    // Find a replacement in the same category that fits the state
    const replacement = PRODUCT_CATALOG.find(
      (p) =>
        p.category === item.category &&
        p.forStates.includes(healthState) &&
        p.id !== item.id
    );

    if (replacement) {
      swaps.push({
        originalId: item.id,
        originalName: item.name,
        swapId: replacement.id,
        swapName: replacement.name,
        reason: `Better fit for your ${healthState} state`,
      });
    }
  }

  return swaps;
}

Call suggestSwaps from your existing smart cart endpoint — pass the user’s current cart item IDs alongside their externalId, and return the swap suggestions in the response. A user in recovery mode with energy bars in their cart sees: “Swap Energy Bars for Bone Broth — better fit for your recovery state.”

Brand voice goes here: “Better fit for your recovery state” is generic. Your implementation should use product-specific and health-signal-specific messaging. For example: “Your sleep debt is high this week — swap the energy drink for chamomile sleep blend.”
These three patterns — timing nudges, scheduled generation, and smart swaps — turn the smart cart from a static recommendation into a reactive system that responds to health changes automatically. Start with timing nudges (simplest to implement), then layer on the others as your product catalog and user base grow.

Troubleshooting

No health data found for this profile +
  • Confirm that your Sahha webhook is active and delivering events — check the Sahha dashboard under Data Delivery → Webhooks
  • Verify the externalId you are querying matches the one set when creating the Sahha profile
  • Check that events exist in your database — look for entries in your events table or collection
  • Try fetching events directly with a database query to confirm data exists before testing the smart cart endpoint
Endpoint always returns recovery +
  • Check if archetypes are flowing into your database — archetype events are less frequent than score events and may take a few days to appear
  • Verify the thresholds match your data — if most of your test profiles have readiness below 0.78, they will always land in recovery
  • Log the intermediate values (avgReadiness, avgActivity, isActiveArchetype) in your categorizeHealthState function to see which branch is being taken
  • Try adjusting the thresholds to match your test data while developing
CORS errors when calling from the frontend +
  • Make sure your endpoint handles both POST and OPTIONS requests with the correct CORS headers
  • Verify that the corsHeaders object is included on all responses — including error responses, not just success
  • Check that your frontend is pointing to the correct endpoint URL for your platform
Products do not change between health states +
  • Check the forStates array on each product in your catalog — make sure products are actually mapped to different states
  • Verify that buildCartForState is filtering by the correct stateId from the API response
  • Try calling the API with different externalId values that have different health profiles — if all your test users have similar data, they may all categorize to the same state
Convex endpoint returns a generic error with no CORS headers +
  • If your Convex httpAction returns HTTP status 502, Convex infrastructure intercepts the response and replaces it with a generic error code: 502 text/plain response — with no CORS headers. Your frontend will see a network error instead of your error message
  • Always use status 500 (not 502) for server errors in Convex httpActions
  • Make sure your CORS headers are included on all error responses, not just success responses
Cart not saving to database +
  • Verify the carts table or collection exists in your database — run the creation SQL or check your Convex schema
  • Check that all required fields are present in the insert — externalId, healthState, items, totalPrice, and status are all required
  • For Supabase, confirm your service role key has insert permissions on the carts table
  • For Convex, ensure the carts table is defined in schema.ts and you have run npx convex dev to sync the schema
  • For Firebase, check that your Firestore security rules allow writes to the carts collection from your Cloud Function
Swap suggestions always empty +
  • Verify that currentCartItems in your request body contains valid product IDs that match the id field in your product catalog
  • Check that your product catalog has items in the same category for the current health state — swaps are category-aware, so if there is no drink in the recovery state, a high-training drink cannot be swapped
  • Confirm that the items in currentCartItems do not already match the current health state — items that already fit the state are skipped
  • Log the healthState and currentCartItems values inside suggestSwaps to verify the inputs

Next steps

You now have a working smart cart with database-backed products, stored carts, interaction tracking, and swap suggestions. Here are some ideas for extending it:

  • Richer health states — Use additional archetypes and biomarkers from the Sahha Data Dictionary for granular states beyond the 3-state model — sleep_regularity, exercise_frequency, and mental_wellness can each drive distinct product mappings
  • Real product API — Replace the seed catalog with a live product database or e-commerce API (Shopify, Stripe, WooCommerce) and sync inventory in real time
  • Cart analytics — Use the interaction events from the cart_events table to build dashboards: acceptance rates by health state, most-swapped products, health-state-to-conversion funnels
  • Notification layer — Wire push notifications or email into the scheduled cart generation flow so users know when their new wellness bundle is ready