MetricsGo API — data contract
What every number served by /api/v1 and the MCP server means. The same text is
returned by the describe_metrics MCP tool. Changing a definition is a breaking
change for every consumer reading that field on a schedule — a new field is added
instead.
Rules that apply everywhere
- Meta ads and organic social days are calendar days in Asia/Kolkata (IST, UTC+5:30); a day runs 00:00:00–23:59:59.999 IST. The Google and Shopify surfaces keep the source platform's own day boundary: GA4 days are in the Analytics property's timezone, Search Console days are Pacific, and Shopify days are in the shop's timezone. Those three will not line up with each other or with IST, and none of them is wrong — each response reports the timezone of the assets behind it.
- null is ABSENCE — the platform did not report the metric — and is never a zero. A genuine zero is 0.
- Ratios are null when their denominator is zero: no CTR without impressions, no cost per result without results.
- Numbers come from stored data synced by MetricsGo; meta.syncedAt on every response says how fresh they are. The API never calls Meta on your behalf.
- Currency amounts are in the ad account's own currency (meta.currency), major units, unrounded.
- A
previousblock and acomparerange are resolved server-side from a named mode (previous period, previous month, previous year). Thecomparefield on the response is the exact window those numbers cover. - Meta has retired several Facebook Page metrics (Page reach, Page impressions, post reach, post impressions). They are null for Facebook, permanently, and are documented as such rather than omitted.
Ads (ads:read)
| Field | Unit | Definition | null means |
|---|---|---|---|
spend |
currency | Amount spent in the range, summed from daily rows. | — |
impressions |
count | Times an ad was on screen, summed from daily rows. | — |
reach |
count | People who saw the ad at least once. Summed over daily rows, so across many days it is an upper bound — Meta de-duplicates reach within a single insights window, and daily rows are separate windows. | — |
clicks |
count | All clicks (Meta clicks), summed. | — |
linkClicks |
count | Clicks on links in the ad (Meta inline_link_clicks), summed. | — |
results |
count | The action Meta counts as this entity's result: the ad set's promoted_object (a custom conversion or standard pixel event) first, then the optimisation goal, then the campaign objective. Matches the Results column in Ads Manager for the same attribution setting. | No result definition applies (e.g. account level, or an objective with no countable result). |
resultType |
label | The Meta action_type that results counts (e.g. offsite_conversion.custom. |
In the overview, null with results > 0 means the range mixes result types (e.g. leads and video views); that total is a sum of unlike things and must not be compared across campaigns — narrow to a client or campaign. Elsewhere: no result definition. |
ctr |
percent | clicks / impressions × 100. | No impressions. |
cpc |
currency | spend / clicks. | No clicks. |
costPerResult |
currency | spend / results. | No results, no result definition, or (overview) the range mixes result types — there is no single thing to price. |
frequency |
ratio | impressions / reach. | No reach. |
account |
label | The ad account the row belongs to: { id, platformAccountId (Meta act_… id), name }. On every entity row and every overview campaign. Join key to /v1/clients. | The account is no longer connected. |
client |
label | The MetricsGo client that account is assigned to: { id, name }. This is how a row is attributed to a client. When the account is not assigned to a client, name falls back to the account name and id is null. | The account is no longer connected (same as account). |
campaign |
label | Parent campaign of an ad set or ad: { id, platformId (Meta campaign_id), name, objective }. | Campaign-level rows have no parent campaign. |
adSet |
label | Parent ad set of an ad: { id, platformId (Meta adset_id), name }. | Only ads have a parent ad set. |
videoViews |
count | 3-second video plays (Meta video_play_actions), summed. | — |
purchaseValue |
currency | Sum of purchase conversion value (Meta action_values for purchase). 0 when never reported. | — |
previous |
count | Totals for the period of the same length immediately before from, with the same entity scope. 0 where the previous period had no data for that metric. |
— |
funds.balance |
currency | Available prepaid balance summed over scoped accounts; falls back to unbilled spend (balance due) for postpaid accounts. | No scoped account reported a balance. |
funds.cap |
currency | Spend cap summed over scoped accounts that have one. | No scoped account has a cap. |
breakdowns.*.reach |
count | Reach within one breakdown slice. NOT additive across slices — one person can appear in several. | — |
stale |
label | Breakdowns only. True when the dashboard would refetch this range from Meta; the numbers may lag until someone opens the entity in the dashboard. | — |
Social (social:read)
| Field | Unit | Definition | null means |
|---|---|---|---|
reach |
count | Accounts reached, summed over daily rows in the range. | Not reported. Permanently null for Facebook Pages (retired by Meta). |
impressions |
count | Retired by Meta for Facebook Pages and never served for Instagram accounts; kept for shape compatibility. | Always null. |
views |
count | Instagram views (the metric that replaced impressions), summed. | — |
profileViews |
count | Instagram profile views, summed. | Not reported; permanently null for Facebook. |
interactions |
count | Instagram total interactions / Facebook page post engagements, summed. | — |
newFollows |
count | New follows in the range, summed. | — |
followers |
count | Follower count at the latest day in the range that has a reading. Point-in-time, not a sum. | No reading in the range. |
engagementRate |
percent | interactions / reach × 100. | Either side missing, or reach is 0. |
postTotals.newPosts |
count | Posts published in the range (IST). Always a real count. | — |
| `postTotals.likes | comments | shares | saves` |
posts[].engagementRate |
percent | (likes + comments + shares + saves) / reach × 100 for one post. | Reach is null or 0. |
posts[].thumbnailHref |
label | URL of the stored thumbnail on this API, requires the same API key. | No thumbnail stored. |
Website — Google Analytics and Search Console (web:read)
| Field | Unit | Definition | null means |
|---|---|---|---|
analytics |
label | The Google Analytics block, with the properties behind it. Null means no Analytics property is attached to the clients in scope — "not connected", not "no traffic". | No GA4 property attached. |
sessions |
count | GA4 sessions, summed over daily rows. The honest volume metric for a range. | — |
totalUsers |
count | Sum of GA4’s DAILY unique users. Somebody who visits on Monday and again on Friday is counted twice, so over a range this is an upper bound on distinct people, not a headcount. | — |
activeUsers |
count | Sum of GA4’s daily active users. Same daily-sum caveat as totalUsers. | — |
newUsers |
count | Users whose first session ever fell in the range, summed daily. | — |
engagedSessions |
count | Sessions GA4 counts as engaged (over 10 seconds, a key event, or 2+ pageviews), summed. | — |
engagementRate |
ratio | engagedSessions / sessions, 0–1 (not a percentage). Recomputed over the whole range, never an average of daily rates. | No sessions. |
bounceRate |
ratio | 1 − engagementRate, 0–1. GA4 defines a bounce as a session that was not engaged — it is not the old Universal Analytics single-pageview bounce. | No sessions. |
averageSessionDuration |
ratio | Seconds per session, weighted by sessions over the range (Σ(duration × sessions) / Σ sessions). | No sessions. |
screenPageViews |
count | Page and screen views, summed. | — |
eventCount |
count | All GA4 events, summed. | — |
keyEvents |
count | Events marked as key events (conversions) in the property, summed. Which events count is the property owner’s configuration, not ours. | — |
conversionRate |
ratio | keyEvents / sessions, 0–1. This product’s one definition of website conversion rate. | No sessions. |
totalRevenue |
currency | GA4 total revenue in the property’s currency (meta.currency), summed. | — |
purchaseRevenue |
currency | GA4 purchase revenue only, summed. Not reconciled with Shopify: GA4 attributes on its own day boundary and only sees what the tag fired. | — |
transactions |
count | GA4 transactions, summed. Will not equal Shopify orders — different timezone, different definition, and GA4 misses what the tag did not see. | — |
revenuePerSession |
currency | totalRevenue / sessions. | No sessions. |
| `channels | sources | landingPages | pages |
organicLandingPages |
label | Entry pages counting ORGANIC SEARCH sessions only. Not a subset of landingPages, whose rows include paid, social and direct traffic to the same page. | — |
partial |
label | True when GA4 collapsed part of at least one breakdown into its "(other)" row, so the tables do not add up to the totals. Cardinality limits in GA4, not missing data. | — |
properties[].timezone |
label | The Analytics property’s reporting timezone — the day boundary every GA4 number here uses. | Not reported by the property. |
search |
label | The Search Console block. Null means no Search Console site is attached to the clients in scope. | No Search Console site attached. |
search.clicks |
count | Clicks from Google search results to the site, summed over days. | — |
search.impressions |
count | Times a link to the site appeared in search results, summed over days. | — |
search.ctr |
ratio | clicks / impressions, 0–1. Recomputed over the range. | No impressions. |
search.position |
ratio | Average position: a RANK, so LOWER IS BETTER — 4.1 beats 8.4. Weighted by impressions (Σ(position × impressions) / Σ impressions), never a plain average: a query seen 10,000 times at rank 3 and one seen twice at rank 90 do not average to 46.5. | No impressions. |
search.positionWeighted |
ratio | Σ(position × impressions), exposed so a consumer can re-weight a subset of rows correctly instead of averaging positions. | — |
search.queries |
label | Top queries by clicks. These rows DO NOT SUM to the site totals: Google withholds queries too few people searched, so query clicks and impressions are always less than the site total. The gap is expected and is not missing data. | — |
| `search.pages | countries | devices | appearances` |
search.searchType |
label | Which Search Console surface the numbers cover: web (default), image, video, news, discover or googleNews. Types are separate indexes and do not add up to a grand total. | — |
sites[].firstIncompleteDate |
label | The first day from which Google’s own data for this site is still settling — numbers on and after it can still rise. | Google reported nothing pending. |
search.days |
label | Search Console days are PACIFIC time, unlike GA4 (the property’s timezone) and Shopify (the shop’s). The three sources will not line up day for day. | — |
Store — Shopify (store:read)
| Field | Unit | Definition | null means |
|---|---|---|---|
store |
label | The Shopify block, with the connected shops behind it. Null means no store is connected for the clients in scope. | No Shopify store connected. |
grossSales |
currency | Product price × quantity, BEFORE tax, shipping and discounts, and INCLUDING cancelled, pending and unpaid orders. Shopify’s own definition; it is not "money received". | — |
discounts |
currency | Discounts applied in the range, as a positive number subtracted from gross sales. | — |
returns |
currency | Refunded value, DATED ON THE REFUND rather than on the original order. A refund can push a day’s net sales negative and never reduces the day the order was placed. | — |
netSales |
currency | grossSales − discounts − returns. Still EXCLUDES shipping and tax. | — |
| `shipping | taxes | duties | tips` |
totalSales |
currency | netSales + shipping + taxes + duties + tips. The bottom line of Shopify’s own finance summary. | — |
orders |
count | Orders placed in the range, including cancelled and unpaid ones (consistent with grossSales). | — |
cancelledOrders |
count | Orders cancelled, counted on the order’s own day. Included in orders, not subtracted from it. |
— |
refundedOrders |
count | Orders with a refund, counted on the refund’s day — the same dating rule as returns. |
— |
unitsSold |
count | Line-item quantity summed over orders in the range. | — |
| `customers | newCustomers | returningCustomers` | count |
averageOrderValue |
currency | netSales / orders, recomputed over the whole range. Never an average of daily averages — a Tuesday with two orders must not weigh as much as a Saturday with two hundred. | No orders. |
averageUnitsPerOrder |
ratio | unitsSold / orders. | No orders. |
returnRate |
ratio | returns / grossSales, 0–1. Because returns are dated on the refund, a short range can show a rate above 1. | No gross sales. |
discountRate |
ratio | discounts / grossSales, 0–1. | No gross sales. |
newCustomerRate |
ratio | newCustomers / customers, 0–1. | No customers. |
revenuePerCustomer |
currency | netSales / customers. | No customers. |
| `products | channels | referrers | countries |
sessions |
count | DOES NOT EXIST. Shopify’s Admin API reports no sessions and no store conversion rate, so neither is served here. Say the data is unavailable rather than deriving one from ad clicks or GA4 sessions — those measure different populations. | Never present; the field is absent entirely. |
stores[].timezone |
label | The shop’s own reporting timezone — the day boundary every Shopify number here uses. It will not match GA4 (the property’s) or Search Console (Pacific). | Not reported by the shop. |
hasAllOrdersScope |
label | False means Shopify granted only the last 60 days of order history for this shop, so earlier days are genuinely absent rather than zero. | — |
