> For the complete documentation index, see [llms.txt](https://docs.millimetric.ai/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.millimetric.ai/recipes/ecommerce.md).

# E-commerce events

Standard e-commerce funnel:

```
viewed_product  →  added_to_cart  →  started_checkout  →  completed_checkout  →  refunded
```

Plus product browse signals (`viewed_collection`, `searched`) and post-purchase (`reviewed_product`).

## Suggested taxonomy

| Event                | When                        | Required properties                                                    |
| -------------------- | --------------------------- | ---------------------------------------------------------------------- |
| `viewed_product`     | product detail page renders | `product_id`, `price_cents`, `currency`, `category`                    |
| `added_to_cart`      | "Add to cart" button        | `product_id`, `quantity`, `price_cents`, `currency`                    |
| `removed_from_cart`  | item removed from cart      | `product_id`, `quantity`                                               |
| `started_checkout`   | checkout page load          | `cart_value_cents`, `currency`, `item_count`                           |
| `applied_discount`   | discount code applied       | `code`, `discount_cents`                                               |
| `completed_checkout` | order placed                | `order_id`, `amount_cents`, `currency`, `item_count`, `payment_method` |
| `refunded`           | refund issued               | `order_id`, `amount_cents`, `currency`, `reason`                       |

Money in **cents** as integers. `currency` always alongside any amount.

## Instrument the storefront

```tsx
import { track } from "@millimetric/track";

export function ProductPage({ product }: { product: Product }) {
  useEffect(() => {
    track("viewed_product", {
      product_id: product.id,
      price_cents: product.priceCents,
      currency: "usd",
      category: product.category
    });
  }, [product.id]);

  return (
    <button onClick={() => addToCart(product, 1)}>Add to cart</button>
  );
}

function addToCart(product: Product, qty: number) {
  cart.add(product, qty);
  track("added_to_cart", {
    product_id: product.id,
    quantity: qty,
    price_cents: product.priceCents,
    currency: "usd"
  });
}
```

## Track the order from the server

```ts
// app/api/checkout/complete/route.ts
import { init, track, flush } from "@millimetric/track-node";

init({ key: process.env.AOA_SK!, host: process.env.AOA_HOST!, flushAt: 1 });

export async function POST(req: Request) {
  const order = await chargeAndPersist(await req.json());

  track({
    event: "completed_checkout",
    anonymous_id: order.anonymous_id,
    user_id: order.user_id,
    properties: {
      order_id: order.id,
      amount_cents: order.totalCents,
      currency: order.currency,
      item_count: order.items.length,
      payment_method: order.paymentMethod,
      is_first_purchase: order.isFirstPurchase
    }
  });

  await flush();
  return Response.json({ ok: true, order_id: order.id });
}
```

For Stripe webhooks, do the same in your webhook handler and pass `order_id` so you can dedupe on retry.

## Refunds

```ts
track({
  event: "refunded",
  user_id: order.user_id,
  properties: {
    order_id: order.id,
    amount_cents: refund.amountCents,
    currency: order.currency,
    reason: refund.reason
  }
});
```

## Querying

### Revenue per day

```sql
SELECT
  toDate(timestamp) AS day,
  sum(JSONExtractInt(properties, 'amount_cents')) / 100.0 AS revenue_usd
FROM events
WHERE project_id = '...'
  AND event_name = 'completed_checkout'
  AND JSONExtractString(properties, 'currency') = 'usd'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY day
ORDER BY day;
```

### Average order value (AOV) by channel

```sql
SELECT
  argMin(source, timestamp)  AS source,
  argMin(medium, timestamp)  AS medium,
  count() AS orders,
  round(avg(JSONExtractInt(properties, 'amount_cents')) / 100.0, 2) AS aov_usd
FROM events
WHERE project_id = '...'
  AND event_name = 'completed_checkout'
  AND timestamp > now() - INTERVAL 30 DAY
GROUP BY anonymous_id
GROUP BY source, medium
ORDER BY orders DESC;
```

### Funnel: view → cart → checkout → order

```sql
WITH steps AS (
  SELECT
    anonymous_id,
    minIf(timestamp, event_name = 'viewed_product')        AS s1,
    minIf(timestamp, event_name = 'added_to_cart')         AS s2,
    minIf(timestamp, event_name = 'started_checkout')      AS s3,
    minIf(timestamp, event_name = 'completed_checkout')    AS s4
  FROM events
  WHERE project_id = '...'
    AND timestamp BETWEEN '2026-05-01' AND '2026-05-17'
  GROUP BY anonymous_id
)
SELECT
  countIf(s1 IS NOT NULL) AS viewed,
  countIf(s2 IS NOT NULL AND s2 >= s1) AS added,
  countIf(s3 IS NOT NULL AND s3 >= s2) AS checked_out,
  countIf(s4 IS NOT NULL AND s4 >= s3) AS purchased
FROM steps;
```

### Cohort retention (do they come back?)

For the cohort that purchased in week W0, what fraction purchased again in week W1, W2, …?

```sql
WITH cohorts AS (
  SELECT
    user_id,
    toMonday(min(timestamp)) AS cohort_week
  FROM events
  WHERE project_id = '...' AND event_name = 'completed_checkout'
  GROUP BY user_id
),
purchases AS (
  SELECT user_id, toMonday(timestamp) AS week
  FROM events
  WHERE project_id = '...' AND event_name = 'completed_checkout'
)
SELECT
  c.cohort_week,
  dateDiff('week', c.cohort_week, p.week) AS week_offset,
  uniq(c.user_id) AS users
FROM cohorts c
JOIN purchases p USING user_id
WHERE p.week >= c.cohort_week
GROUP BY c.cohort_week, week_offset
ORDER BY c.cohort_week, week_offset;
```

## Common pitfalls

* **`amount` instead of `amount_cents`.** Floats are not your friend in revenue math. Always use integer cents and store `currency` explicitly.
* **Tracking `completed_checkout` from the success page.** Anyone refreshing it double-counts. Track from the server, on order creation, with `event_id = order.id` so you can dedupe in queries.
* **Mixing currencies.** Don't sum `amount_cents` across rows with different `currency` values. Convert in the query, or split by currency.
* **No `is_first_purchase` flag.** It's trivial to compute, easier in queries, and answers half the marketing-attribution questions cleanly.

## See also

* [Properties](/core-concepts/properties.md) — full conventions.
* [Marketing attribution dashboards](/recipes/marketing-attribution.md) — credit channels for revenue.
* [Server-side events](/recipes/server-side.md) — why critical events should come from the server.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter:

```
GET https://docs.millimetric.ai/recipes/ecommerce.md?ask=<question>
```

The question should be specific, self-contained, and written in natural language.
The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
