Skip to main content
Documentation

Reports

reports.metrics

Read the whole business report card, metric by metric
Read-onlyRead budget

REST

Shipping
POST /api/v1/reports/metrics

MCP tool

Live
reports.metrics

Exposed on: REST API · Shop MCP server · Claude connector · Dash (in-app copilot) · Zapier. Part of the Reports domain.

Operating contract

EVERY NUMBER THE ADVISOR JUDGES A SHOP ON, computed live: revenue and margin, quote-to-job conversion, average ticket, rebook rate, first-response time, review rating, film waste, inventory turns — each against the same window a month earlier.

USE THIS RATHER THAN ASSEMBLING YOUR OWN PICTURE from six other reports. It is one consistent window, one currency, one clock, and it is the exact snapshot the AI advisor is grounded in — so an answer built from it cannot contradict the advice the shop reads on its own Insights page.

THE INDUSTRY LENS DECIDES WHAT MATTERS. weight is 3 for the levers that make or break this kind of shop and 1 for context; a metric weighted 0 for this industry is not returned at all. Film waste is a headline for a wrap shop and noise for a detailer, and reporting them as equals is how advice gets ignored.

concerning: true means the metric moved the WRONG WAY beyond noise (±3%). That is the shortlist. A shop owner reading twenty numbers acts on none of them; a shop owner reading the three that got worse acts.

value: null IS NOT ZERO. It means there is no basis to compute it — no calls logged, no timers used, no spend recorded. Reporting a null as zero invents a catastrophe out of a feature the shop does not use, and notes says which ones those are, in the product's own words.

why is one sentence on how that metric moves money, written for an owner. Quote it rather than inventing a rationale.

This computes the whole snapshot and returns all of it, so nothing is truncated — but the underlying reads have row caps of their own, so treat a very large shop's figures as a floor.

Who may call it

Permission
reports.accessThe caller must hold Reports & insights at the ACT level. A read-only dashboard grant on the same section is refused.
Plan
Every planNo plan gate. Available on every Service VIN plan.
Retries
naturalNaturally idempotent — running it twice leaves the same world as running it once. A retrying integration needs no key.
Rate class
readCounted against the read budget — the widest of the four.

Input

FieldTypeDescription
rangestring

The window: 7d, 30d, 90d, 12mo, ytd or all. Defaults to 30d, which is what the advisor runs on.

Default: "30d"One of: 7d, 30d, 90d, 12mo, ytd, all
concerning_onlyboolean

True returns only the metrics that moved the wrong way beyond noise.

Output

FieldTypeDescription
rangestring

computed_atstring

currencystring

industrystring

metricsobject[]

metrics[].keystring

metrics[].labelstring

metrics[].categorystring

metrics[].unitstring

metrics[].good_directionstring

metrics[].whystring

metrics[].weightnumber

metrics[].valuenumber | null

metrics[].previousnumber | null

metrics[].change_pctnumber | null

metrics[].concerningboolean

top_servicesobject[]

top_services[].namestring

top_services[].valuenumber

top_services[].countnumber | null

top_customersobject[]

top_customers[].namestring

top_customers[].valuenumber

top_customers[].countnumber | null

trendobject[]

trend[].labelstring

trend[].revenuenumber

notesstring[]

Examples

Built from this capability's own schema — required fields and the ones carrying a default, and nothing invented. Paste one and it validates.

curl
export SERVICEVIN_API_KEY=svk_live_…

curl -X POST https://www.servicevin.com/api/v1/reports/metrics \
  -H "Authorization: Bearer $SERVICEVIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"range":"30d"}'

TypeScript (fetch)
const res = await fetch("https://www.servicevin.com/api/v1/reports/metrics", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${process.env.SERVICEVIN_API_KEY}`,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    "range": "30d"
  }),
});

// Success and failure are both envelopes. Switch on error.code, never
// on error.message — the codes are stable, the messages are for people.
const payload = await res.json();
if (!res.ok) throw new Error(payload.error.code);
const data = payload.data;

Python (requests)
import os, requests

res = requests.post(
    "https://www.servicevin.com/api/v1/reports/metrics",
    headers={"Authorization": f"Bearer {os.environ['SERVICEVIN_API_KEY']}"},
    json={
    "range": "30d"
},
    timeout=30,
)
payload = res.json()
if not res.ok:
    raise RuntimeError(payload["error"]["code"])
data = payload["data"]

MCP tools/call — https://www.servicevin.com/api/mcp
{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "reports.metrics",
    "arguments": {
      "range": "30d"
    }
  }
}

Refusals

The four gates run in this order on every surface, and the order is not arbitrary — see Authentication.

StatusCodeWhen
404not_foundThe id is unknown, or the feature is not enabled for this account. Deliberately the same answer for both.
403forbiddenThis login does not hold reports.access.
422validation_errorAn argument was wrong. The message names the field.
429rate_limitedToo many read calls. Back off and retry.
500internal_errorSomething failed on our side. Nothing was changed.