Skip to main content
Documentation

Reports

reports.by_service

Break a period's revenue and margin report down by service
Read-onlyExpensive budget

REST

Shipping
POST /api/v1/reports/by_service

MCP tool

Live
reports.by_service

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

Operating contract

Which services actually made the shop money over the window — revenue, cost, profit and margin per service, biggest revenue first.

THE INTERESTING ROW IS RARELY THE BIGGEST ONE. A service with high revenue and thin margin is worth less than a smaller one at 60%, and this is the read that shows the difference. Say the margin alongside the revenue every time.

Other / uncategorized is where money that could not be attributed to a catalog service lands — ad-hoc lines somebody typed on an invoice. A large uncategorized row means the shop is selling work that is not on its menu, which is worth mentioning: nothing about it can be priced, scheduled or reported on properly.

jobs is how many line rows fed the row, not how many customers. Two lines on one invoice count twice.

COST AND MARGIN ARE STAFF-ONLY. Never repeat them to a customer.

truncated is a flag rather than a count: when it is true a row cap bit and every figure on this response UNDERCOUNTS. Narrow the range and ask again, and say the period could not be read in full rather than quoting the totals.

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
expensiveCounted against the expensive budget — a model call, a document render or a fan-out scan.

Partial answers

truncated is how many matching records are not in the response, and 0 is a real answer meaning you have all of them. There is no cursor here — narrow the filter instead.

Do not summarise from a truncated response

`truncated` is a flag rather than a count: when it is true a row cap bit and every figure on this response UNDERCOUNTS. Narrow the range and ask again, and say the period could not be read in full rather than quoting the totals.

Input

FieldTypeDescription
rangestring

The window: 7d, 30d, 90d, 12mo, ytd or all. Defaults to 90d.

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

Output

FieldTypeDescription
rangestring

currencystring

truncatedboolean

itemsobject[]

items[].servicestring

items[].service_idstring | null

items[].revenuenumber

items[].costnumber

items[].profitnumber

items[].margin_pctnumber

items[].line_countnumber

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/by_service \
  -H "Authorization: Bearer $SERVICEVIN_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"range":"90d"}'

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

// 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/by_service",
    headers={"Authorization": f"Bearer {os.environ['SERVICEVIN_API_KEY']}"},
    json={
    "range": "90d"
},
    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.by_service",
    "arguments": {
      "range": "90d"
    }
  }
}

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 expensive calls. Back off and retry.
500internal_errorSomething failed on our side. Nothing was changed.