Documentation
Reports
reports.job_pl
REST
ShippingPOST /api/v1/reports/job_plMCP tool
Livereports.job_plExposed on: REST API · Shop MCP server · Claude connector · Dash (in-app copilot) · Zapier. Part of the Reports domain.
Operating contract
Every completed job in the window with what it actually made: revenue, material cost, labour from the pay ledger, and the gross profit between them.
complete VERSUS incomplete IS THE FIRST THING TO READ. A job is complete only when every cost is known; an incomplete one is missing labour, material or both, and its profit figure is a guess. The totals cover COMPLETE JOBS ONLY, deliberately — money a human can stand behind.
flagged counts complete jobs whose ACTUAL profit came in materially below the estimate. Those are the jobs worth opening: something was quoted at a price the work did not support, and it usually repeats.
labour_burden is employer cost on top of gross pay and is zero unless the shop set a multiplier. When it is not zero, every profit figure here is after it, and quoting profit at a different burden than the screen shows is how two people end up arguing about the same job.
unattributed_labour is pay in the window that carries NO job — hourly shifts, mostly. It is real money the shop spent and it is not in any job's cost, so a shop reading only the job rows is understating what its floor costs.
excluded on a by-service or by-installer row counts jobs in that bucket left OUT for incomplete data. They are never counted as zero, which is why a row's job count can be lower than the work the shop remembers doing.
cost_kind_supported: false means this shop's database predates cost classification, so every free-hand cost reads as unclassified. That is not the shop failing to classify anything.
COST, MARGIN AND PAY ARE STAFF-ONLY. None of it goes 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 |
| service_id | stringuuid | Narrow to one service, as returned by services.list. |
| staff_id | stringuuid | Narrow to one installer's staff profile id, as returned by staff.list. |
Output
| Field | Type | Description |
|---|---|---|
| range | string | — |
| currency | string | — |
| truncated | boolean | — |
| has_data | boolean | — |
| cost_kind_supported | boolean | — |
| totals | object | — |
| totals.revenue | number | — |
| totals.material_cost | number | — |
| totals.labour_cost | number | — |
| totals.labour_burden | number | — |
| totals.other_cost | number | — |
| totals.cost | number | — |
| totals.gross_profit | number | — |
| totals.margin_pct | number | null | — |
| totals.estimated_margin | number | — |
| totals.estimated_jobs | number | — |
| totals.actual_of_estimated | number | — |
| counts | object | — |
| counts.jobs_in_range | number | — |
| counts.matched | number | — |
| counts.complete | number | — |
| counts.incomplete | number | — |
| counts.flagged | number | — |
| counts.unclassified_cost | number | — |
| unattributed_labour | object | — |
| unattributed_labour.amount | number | — |
| unattributed_labour.lines | number | — |
| burden | object | — |
| burden.multiplier | number | — |
| burden.applied | boolean | — |
| by_service | object[] | — |
| by_service[].label | string | — |
| by_service[].jobs | number | — |
| by_service[].excluded | number | — |
| by_service[].revenue | number | — |
| by_service[].profit | number | — |
| by_service[].margin_pct | number | null | — |
| by_installer | object[] | — |
| by_installer[].label | string | — |
| by_installer[].jobs | number | — |
| by_installer[].excluded | number | — |
| by_installer[].revenue | number | — |
| by_installer[].profit | number | — |
| by_installer[].margin_pct | number | null | — |
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/job_pl \
-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/job_pl", {
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/job_pl",
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.job_pl",
"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. |