Analytics

Revenue, orders, customers and storefront views without cookies: the aggregates, the agent tools and search performance from connected engines and imports.

Written 2026-09-16. What a store knows about itself: money, orders, customers and storefront traffic, in one shape a merchant reads on Home and an agent reads with one tool call. Everything is computed inside the store's own database from rows it already has, plus a page-view counter that needs no cookie and no consent banner.

1. What is counted, and what is not

Formahand counts page views, not people. Every storefront HTML page a browser loads adds one to a counter for that day and that path, and the same request adds one to a counter for the referring host. The same request also counts toward a session (§2a), with no cookie either.

Never written, anywhere:

  • No cookie and no local storage. Nothing is set in the shopper's browser for analytics, so no consent banner is needed for it.
  • No device fingerprint. No canvas, no font probing, no screen metrics, no header fan-out.
  • No visitor profile. Nothing describes a person. A session (§2a) is kept only as a short-lived count under the daily visitor hint, deleted after two days.
  • No IP address and no user agent. Neither is stored, in any form, raw or encrypted.
  • No search terms. A visit to /search?q=… is counted as /search; the query string is dropped before the write.
  • No referring page. Only the host of the referrer is kept: example.com, never example.com/some/page?utm_source=…. The path, the query and every utm parameter are discarded at the door.
  • Nothing leaves the store. The counters live in the store's own database next to its orders. Analytics adds no third-party script, pixel or beacon to any storefront page. Advertising pixels load only if you switch one on (Analytics, Pixels), and only as the shopper's tracking choice allows.

The visitor hint

A counter alone cannot tell one person reading five pages from five people reading one. So each request derives a short hash of three coarse things (the day, the network the request came from, cut to its /24 for IPv4 or its /48 for IPv6, and a browser family such as "chrome, mobile") and remembers only that hash, for two days.

The first page a hash is seen on that day adds one to unique_hint on that path's row; later pages that day add nothing. That makes unique_hint a coarse count of the day's visitors, attributed to the page each one landed on. It is a hint, not a measurement: a household behind one address counts once, a phone moving between networks counts twice. The hash cannot be reversed to an address, it changes every day, and the rows that hold it are deleted after two days, so nothing follows anyone from one day to the next.

What is skipped

Bots (matched against a list of user-agent fragments: crawlers, link previewers, uptime checkers, command-line clients), API responses, media files, anything with a file extension, and any response that is not a 200 HTML page. The count happens after the page has been sent, so a storefront is never slower for being measured.

2. Aggregates

GET /api/analytics?from=&to=&compare= (session, or a token with orders:read; MCP: get_analytics) returns one period:

{
  "period": { "from": "2026-08-18", "to": "2026-09-16", "days": 30, "timezone": "UTC" },
  "currency": "USD",
  "revenueMinor": 412300, "orders": 54, "paidOrders": 48,
  "averageOrderMinor": 8589, "refundedMinor": 12000,
  "abandonedCheckouts": 21, "abandonedRate": 0.304,
  "newCustomers": 31, "repeatRate": 0.22,
  "bestSellers": [{ "productId": "prod_…", "name": "Oat Granola", "quantity": 62, "revenueMinor": 74400 }],
  "views": {
    "total": 4820, "uniqueHint": 1611,
    "byKind": { "home": 1204, "product": 2310, "collection": 610, "page": 96, "search": 140, "cart": 402, "checkout": 58, "other": 0 },
    "topPaths": [{ "path": "/products/oat-granola", "views": 812 }]
  },
  "topReferrers": [{ "host": "(direct)", "views": 2110 }, { "host": "instagram.com", "views": 640 }],
  "checkoutViews": 460, "conversion": 0.104,
  "series": [{ "day": "2026-08-18", "revenueMinor": 12400, "orders": 2, "views": 141, "checkoutViews": 16 }],
  "compareTo": { "…": "the same shape for the period immediately before, or null" }
}
FieldHow it is computed
revenueMinorTotal of every order paid inside the period, in minor units of currency. A later refund does not remove it; refundedMinor stands beside it.
orders / paidOrdersEvery order the period touched, and the ones that were paid (including those refunded afterwards).
averageOrderMinorrevenueMinor / paidOrders, rounded; 0 when nothing was paid.
refundedMinorMoney refunded on orders in the period.
abandonedCheckoutsCheckouts marked abandoned (started, never paid, an hour gone; see automations.md).
abandonedRateabandoned / (abandoned + paidOrders); null when neither happened.
newCustomersContacts first seen in the period.
repeatRateOf the people who bought in the period, the share who had ordered before; null when nobody bought.
bestSellersUp to ten products by units sold, from the lines of paid orders, with the revenue those lines carried.
viewsThe page-view counters above: total, the visitor hint, a breakdown by page kind and the ten busiest paths.
topReferrersThe ten busiest referring hosts. (direct) is a visit that arrived with no referrer at all.
checkoutViewsViews of the cart and checkout pages, the last storefront pages before payment.
conversionpaidOrders / checkoutViews; null when there were no such views.
seriesOne entry for every day of the period, in order, including the days nothing happened: { day, revenueMinor, orders, views, checkoutViews }. orders and revenueMinor are the same two counts as the totals above, split by day, so the series sums to them. Two grouped queries over the rows the store already has: no extra table, and no row is written for an empty day.
compareToThe same object for the period of equal length ending the day before from, when compare is set. Absent (null) otherwise.

from and to are ISO days (YYYY-MM-DD), inclusive, in the store's timezone when it has one and UTC otherwise; the period they describe is at most 366 days. Without them the last 30 days ending today. Money is always minor units (cents) of currency, the currency most of the period's orders used.

Page-view days are UTC days. When a store sets a timezone, orders and customers move with it and the view counters stay on UTC, so the two can disagree by a few hours' worth of traffic at the edges of a period. The same is true inside one series entry: its revenueMinor and orders are the store's local day, its views and checkoutViews the UTC day of the same name.

2a. Sessions and the funnel

A session is one visit. It starts on the first page a person opens and ends after 30 minutes without a page. It comes from the same daily visitor hint as uniqueHint (§1), so it needs no cookie. A new UTC day starts a new session, because the hint changes every day. The state behind a session is deleted after two days and holds no address and no browser string.

  • Bounced: one page view and nothing else.
  • Engaged: more than one page, or an add to cart, or checkout.
  • Sessions of the last 30 minutes are still open. They count as sessions now and are decided (bounced or engaged) later, so bounced + engaged can be less than total.

The funnel has four steps, each counted once per session:

  1. Sessions.
  2. Added to cart: the session put a product in the cart.
  3. Reached checkout: the session opened the store's checkout page, or came back from paying.
  4. Converted: an order was placed in the session. The order is counted the moment its thank-you page is seen.

sessions.conversionRate is converted sessions over all sessions. It is the number to quote as the store's conversion rate. The older conversion (paid orders over cart and checkout views, §2) stays for continuity.

Each session is filed under the source of its first page, with the same channels as Sales sources. Not recorded (channel "") is a session whose shopper's tracking choice kept the source out, by the same rule as order sources. The session still counts.

get_analytics and GET /api/analytics answer it as sessions:

"sessions": {
  "total": 1840, "bounced": 1012, "bounceRate": 0.55,
  "engaged": 812, "engagedRate": 0.441,
  "withCart": 221, "reachedCheckout": 96, "converted": 48, "conversionRate": 0.026,
  "byChannel": [{ "channel": "social", "sessions": 640, "converted": 14, "conversionRate": 0.022 }],
  "byDevice": [{ "device": "mobile", "sessions": 1220, "converted": 27, "conversionRate": 0.022 }],
  "byLandingKind": [{ "kind": "product", "sessions": 910, "converted": 30, "conversionRate": 0.033 }]
}
FieldMeans
totalSessions that started in the period (UTC days, like page views).
bounced / bounceRateSessions with one page and nothing else, and their share of total.
engaged / engagedRateSessions with more than one page, an add to cart or checkout, and their share of total.
withCartSessions that added a product to the cart.
reachedCheckoutSessions that reached checkout.
converted / conversionRateSessions with an order, and their share of total.
byChannelThe same counts per channel, busiest first. "" is Not recorded.
byDevicePer device: desktop, mobile, tablet or other, read from the browser and never stored.
byLandingKindPer kind of first page: home, product, collection, page, search, cart, checkout or other.

A rate is null when there were no sessions. A store with no visit yet answers zeros.

How a session is counted

  • Every page is counted after it was sent, so measuring never slows the store. Bots, previews and the page builder are skipped, as for page views.
  • An add to cart is counted when the shopper's cart gains a product: the cart the browser sent is compared with the cart it got back.
  • Any checkout page of the store, the thank-you page included, counts as reached checkout.
  • The thank-you page links the order to the session and marks it converted. When the payment is confirmed, the order's journey and its product counts are written once. Whichever comes first, nothing is counted twice.
  • A session idle for more than 30 minutes is decided (bounced or engaged) when the same visitor opens a page again, from time to time on any page, and every time the numbers are read.
  • Every visit with a source in a session is kept as a touch, at most 20, for the attribution models in Sales sources. A direct visit is never a touch.
  • Counts are kept per UTC day. No row is written for an empty day.

2b. Products by source

Per product, for a period: views, added to cart, the add to cart rate and its band, orders, units and sales. Each product splits by where its visits came from: by channel, by source, by campaign or by tracked link.

Add to cart rateBand
Below 6 percentbelow
6 to 8 percentaverage
8 to 10 percentstrong
10 percent and overexcellent

The rate is adds to cart over product views. With fewer than 20 views the band is still given, with the note "Few views yet".

  • Views and adds to cart are counted in the session, under the session's source.
  • Orders, units and sales count paid orders (paid, or paid and later refunded), credited to the last touch before the order (Sales sources).
  • Purchasers are the distinct buyers of the product in the 180 days ending the period, whatever the period is. Paid-ready means 100 purchasers or more: the floor for a lookalike audience on most ad channels.
  • Channel posts. A tracked link row may carry the channel post that used it, as post (provider, title, externalUrl, publishedAt). Each product says how many channel posts link to it, as posts.
{ "period": { "from": "2026-09-10", "to": "2026-10-09", "days": 30, "timezone": "UTC" },
  "currency": "USD", "groupBy": "channel",
  "bands": { "average": 0.06, "strong": 0.08, "excellent": 0.1 }, "paidReadyMinimum": 100,
  "products": [{ "productId": "prod_…", "name": "Blush Bloom",
    "views": 412, "addToCart": 38, "viewToCartRate": 0.092, "band": "strong", "bandNote": "",
    "orders": 31, "units": 34, "revenueMinor": 105400, "cartToOrderRate": 0.816,
    "purchasers": 97, "paidReady": false,
    "paidReadyNote": "3 purchasers short of a lookalike seed (100 in 180 days).", "posts": 2,
    "bySource": [{ "key": "Social", "channel": "social", "sourceName": "", "campaign": "", "linkCode": "",
      "views": 220, "addToCart": 24, "viewToCartRate": 0.109, "orders": 18, "units": 20, "revenueMinor": 61200 }] }] }

Products are sorted by views, then sales, at most 200. A deleted product keeps its id and an empty name. Money is minor units of currency.

Limits.

  • Sessions have no cookie, so they cannot see the same person across days or devices.
  • A product added from a collection page without opening it is still counted on the add, but has no view.
  • Reached checkout needs the store's own checkout page. A custom front end that sends shoppers to pay on its own is seen only when they come back from paying.

2c. Audience

Who came, what they did and what it earned after product cost. One table of daily counters, with no cookie, no visitor row and no identifier. The session's two-letter country is kept with the two-day session state and nowhere else: it is needed to file the order under the right country, so it is kept there also for a shopper who refused tracking, and it goes when that state is pruned.

The dimensions.

DimensionValuesKnown when
countryThe two-letter country the visit came from, upper case. "" is unknown.The session starts
devicedesktop, mobile, tablet or other, as sessions have it (§2a)The session starts
channelThe session's channel, as sessions have it. "" is Not recorded; direct is a real direct visit.The session starts
visitorKindnew or returning: whether the customer had an earlier paid order in this store. "" when the order names no customer.The order only

The counters.

CounterCounts
sessionsSessions that started
engagedSessions decided as engaged
withCartSessions that added a product to the cart
reachedCheckoutSessions that reached checkout
convertedSessions with an order
ordersPaid orders (paid, or paid and later refunded)
unitsUnits sold on those orders
revenueMinorTheir product revenue: the order's total less its tax and less the shipping it charged. Refunds are not taken out.
costMinorThe product cost of the units that have one
uncostedUnitsUnits sold with no product cost

Two rules a reader must know.

  1. **Session counters sit on visitor kind "". Order facts sit on new or returning.** New or returning is known only at the order, so no session is ever filed under it. Never divide a new or returning row by sessions. An order with no customer puts its order facts on "" too.
  2. The five session counters always add up to the Sessions numbers of the same day. sessions, engaged, withCart, reachedCheckout and converted, summed over every audience row of a day, equal that day's numbers in §2a. Both are written together, so they cannot disagree. Bounced is not kept here.

Session counters are filed on the UTC day the session started. Order facts are filed on the day the order was paid, under the country, device and channel of the session it came from. An order paid with no session seen has no country, device other and the channel its source recorded.

An order belongs to one session, once. The first session to open the order's thank-you page is the one it is filed under. Opening that page again, at the same moment or later, from the same visitor or anyone else, moves nothing.

Revenue here is product revenue, so it is lower than the Sales card's revenue (total less tax) by the shipping the orders charged. Tax is held for a tax authority and shipping pays for delivery; neither is money a product cost is set against. Refunds are not taken out: read refundedMinor of get_analytics beside it.

Margin is revenueMinor less costMinor. Product cost is entered per product or per variant; a variant's own cost wins. A unit without a cost counts 0, so with many uncostedUnits the margin reads too high. The share of costed units is (units - uncostedUnits) / units; the store comparison hides the margin when that share is under 90 percent.

Offers. offers lists up to 20 discount codes (upper case) and pricing rule names, each with the paid orders of the period that used it and its share of all paid orders. An order counts once per offer.

GET /api/analytics/audience?storeId=&from=&to=&groupBy= (session, or a token with orders:read; MCP: get_audience) answers:

{ "period": { "from": "2026-09-13", "to": "2026-10-12", "days": 30, "timezone": "UTC" },
  "groupBy": ["country", "visitorKind"],
  "rows": [
    { "country": "US", "device": "", "channel": "", "visitorKind": "",
      "sessions": 1220, "engaged": 540, "withCart": 150, "reachedCheckout": 66, "converted": 31,
      "orders": 0, "units": 0, "revenueMinor": 0, "costMinor": 0, "uncostedUnits": 0 },
    { "country": "US", "device": "", "channel": "", "visitorKind": "new",
      "sessions": 0, "engaged": 0, "withCart": 0, "reachedCheckout": 0, "converted": 0,
      "orders": 24, "units": 29, "revenueMinor": 205000, "costMinor": 71000, "uncostedUnits": 4 }],
  "totals": { "…": "the same counters, summed" },
  "offers": [{ "name": "3 FOR 100", "orders": 19, "share": 0.396 }] }
  • groupBy is a comma list of country, device, channel and visitorKind. A dimension not asked for is "" on every row. Without it there is one row for the whole period.
  • Rows are sorted by sessions, then orders.
  • from and to follow the rules of §2. An unknown name answers "groupBy is country, device, channel or visitorKind."
  • A store with no visit yet answers no rows and zero totals.

The Audience card sits on Analytics → Overview, for the period of the Sales card:

  • Top countries: the five busiest, by sessions, with each one's share. An unknown country reads "Unknown".
  • Devices: Desktop, Phone, Tablet, Other, each with its sessions and conversion.
  • New or returning: New, Returning and Unknown, each with its orders, revenue and share of orders, and the line "Known from the first order."
  • Empty, it says "No audience yet": "Country and device appear after the first visit; new or returning after the first order."

To set several stores side by side on these numbers, see store-comparison.md.

3. Search performance

What search engines report about your store: the searches it appeared for, the pages shown, clicks, impressions and average position. The rows come from two places into one table.

  • Connected engines. Connect Google or Bing on the Search engines screen (#search-engines, see Search engines) and Formahand reads the engine about once a day. The first read goes back 90 days. The data is about two days behind, and the last three days are read again each day because the engine can still change them. Rows read from an engine are kept for 16 months.
  • Your own exports. For an engine you have not connected, or history from before you connected, upload the engine's performance export: Import CSV on the Search performance card, or an agent with import_search_performance { rows: [{ day, query, page, clicks, impressions, position, engine?, country?, device? }] }. At most 5,000 rows a call. A row replaces the one stored for the same day, engine, query, page, country and device, so re-uploading a corrected export is safe; a connected engine's daily read replaces an uploaded row with the same key. Queries are lower-cased; ctr is derived from clicks and impressions. engine is google, bing or other (default google).

get_search_performance { from, to, groupBy?, engine?, country?, device?, limit? } reads it back. groupBy is query (the default), page, engine, country, device or day; day is a series in date order, every other grouping ranks by clicks. engine, country (the engine's three-letter code, such as usa) and device (desktop, mobile or tablet) narrow the rows, and filters echoes them when one was given:

{ "period": { "from": "2026-08-18", "to": "2026-09-16", "days": 30 },
  "groupBy": "query",
  "rows": [{ "key": "oat granola", "clicks": 84, "impressions": 2310, "ctr": 0.036, "position": 7.4 }],
  "totals": { "clicks": 611, "impressions": 20400, "ctr": 0.03, "position": 11.2 },
  "filters": { "engine": "google", "country": null, "device": "mobile" } }

Position is the average weighted by impressions; ctr is clicks divided by impressions. Google leaves out rare searches, so rows can add up to less than the totals. Formahand reads Bing by search and day only, so Bing rows have no page, country or device and group under an empty key.

An empty answer means no connected engine has reported those days yet and nothing was uploaded for them, not that the store has no traffic. describe_search_engines shows which engines are connected. A sandbox is never connected to a search engine; it has search rows only if you upload them there.

In the dashboard the Search performance card on Analytics shows the last 90 days, with All, Google or Bing and grouping by Terms, Pages, Countries or Devices. When nothing is there yet and no engine is connected, it links to Connect a search engine.

4. Agent tools

ToolScopeDoes
get_analyticsorders:read{ from?, to?, compare? } → the object in §2, with sessions (§2a). Read-only.
get_product_performanceorders:read{ from?, to?, productId?, groupBy? } → the report in §2b, plus summary (one sentence per product, the ten most viewed first, such as "Blush Bloom: 412 views, 9.2% added to cart (strong), 31 orders, 3 purchasers short of a lookalike seed.") and notes. groupBy is channel (the default), source, campaign or link. Read-only.
get_audienceorders:read{ from?, to?, groupBy? } → the object in §2c: rows by country, device, channel and new or returning, with totals and offers. Read-only.
get_top_productsorders:read{ period: "7d" | "30d" | "90d" | "365d" } → the best sellers, the paid orders and the revenue of that window. Read-only.
import_search_performancesettings:write{ rows, source? } → { imported: { rows, days, from, to, source } }.
get_search_performanceorders:read{ from, to, groupBy?, engine?, country?, device?, limit? } → the object in §3. Read-only.

A useful first call for an agent asked "how is the store doing?":

get_analytics { compare: true }

One call answers the money, the orders, the customers, the traffic and whether each of them moved since the period before. Then get_product_performance {} answers which product sells and which channel sends its buyers.

5. In the dashboard

The Sales card lives on Analytics → Overview (#analytics). Home no longer carries it: once the store has sold something, Home shows its metric tiles and one link, Open analytics, that goes there.

  • Today · 7d · 30d · 90d pills and a Compare switch in the card head. The pill is remembered per store in the browser; 30d is the default.
  • Four tiles (Total sales, Orders, Average order, Conversion), each with the move against the period before as a badge, and a sparkline of its own days.
  • Total sales over time as a filled area with a thin line, the previous period behind it as a dashed grey line, beside Orders a day as bars.
  • Top products as horizontal bars (money, with the quantity beside it) and Where visits come from as one row per referring host, with Top pages behind a row that opens.
  • "More numbers" opens abandoned checkouts, new customers, refunds and storefront views.

The Sessions card sits under the Sales card on the same screen, for the same period:

  • Three tiles: Sessions, Bounce rate and Conversion (converted sessions over sessions).
  • Funnel as four bars: Sessions, Added to cart, Reached checkout, Converted.
  • By channel: one row per channel with its sessions and conversion. Not recorded is one grey row.
  • With no session yet the card says "Sessions appear after the first visit."

The Products screen under Analytics (#product-performance) is a table of products: name, views, added to cart, the rate with its band, orders, sales and a paid-ready mark. Open a product (#product-performance/<productId>) to see its rows by Channel, Source, Campaign or Link, with the channel post beside a tracked link. With no product view yet it says "Product views appear after the first visit." The dashboard reads it from GET /api/analytics?report=products&from=&to=&productId=&groupBy=, with the same scope as get_analytics.

Every chart is inline SVG with no library, role="img" with a spoken summary, and tabular numerals; a chart whose every value is zero is replaced by one line, "No sales yet." Every number is the store's own: there is no sample data anywhere in the card.

Moving the mouse or a finger across the area or the bars marks the nearest day: a hairline, a dot on the line and a small tooltip with the day and its value. With Compare on, the tooltip carries a second line for the same day the period before and the move between them. The charts take the keyboard too: tab to one, then the left and right arrow keys walk day by day (Home and End jump to the ends) and Escape clears the marks, with every step read out. The tiles' sparklines show the same tooltip without the hairline. Nothing on the card moves until a pointer or a key asks it to, and the marks stop gliding when the reader has asked for less motion.

See also

  • store-comparison.md: the stores of one group side by side, with verdicts.
  • order-sources.md: where each paid order came from, and the attribution models.
  • agents.md: the analytics tools, their scopes, and the recommended order of operations.
  • content-seo.md: the per-page titles and descriptions the search-performance rows tell you to fix.
  • checkout-rules.md: measure a pricing rule's effect with compare on the period before it.
  • automations.md: a scheduled flow can mail you the same numbers every morning.
  • headless.md: what is still counted when the storefront is yours.