Documentation
Reports
reports.by_service
REST
ShippingPOST /api/v1/reports/by_serviceMCP tool
Livereports.by_serviceExposed 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
Input
| Field | Type | Description |
|---|---|---|
| range | string | The window: 7d, 30d, 90d, 12mo, ytd or all. Defaults to 90d. Default:"90d"One of: 7d, 30d, 90d, 12mo, ytd, all |
Output
| Field | Type | Description |
|---|---|---|
| range | string | — |
| currency | string | — |
| truncated | boolean | — |
| items | object[] | — |
| items[].service | string | — |
| items[].service_id | string | null | — |
| items[].revenue | number | — |
| items[].cost | number | — |
| items[].profit | number | — |
| items[].margin_pct | number | — |
| items[].line_count | number | — |
Examples
Built from this capability's own schema — required fields and the ones carrying a default, and nothing invented. Paste one and it validates.
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"}'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;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"]{
"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.
| Status | Code | When |
|---|---|---|
| 404 | not_found | The id is unknown, or the feature is not enabled for this account. Deliberately the same answer for both. |
| 403 | forbidden | This login does not hold reports.access. |
| 422 | validation_error | An argument was wrong. The message names the field. |
| 429 | rate_limited | Too many expensive calls. Back off and retry. |
| 500 | internal_error | Something failed on our side. Nothing was changed. |