Skip to main content
Documentation

Reports

reports.job_pl

Read the per-job profit-and-loss report for completed work
Read-onlyExpensive budget

REST

Shipping
POST /api/v1/reports/job_pl

MCP tool

Live
reports.job_pl

Exposed 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

`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
service_idstringuuid

Narrow to one service, as returned by services.list.

staff_idstringuuid

Narrow to one installer's staff profile id, as returned by staff.list.

Output

FieldTypeDescription
rangestring

currencystring

truncatedboolean

has_databoolean

cost_kind_supportedboolean

totalsobject

totals.revenuenumber

totals.material_costnumber

totals.labour_costnumber

totals.labour_burdennumber

totals.other_costnumber

totals.costnumber

totals.gross_profitnumber

totals.margin_pctnumber | null

totals.estimated_marginnumber

totals.estimated_jobsnumber

totals.actual_of_estimatednumber

countsobject

counts.jobs_in_rangenumber

counts.matchednumber

counts.completenumber

counts.incompletenumber

counts.flaggednumber

counts.unclassified_costnumber

unattributed_labourobject

unattributed_labour.amountnumber

unattributed_labour.linesnumber

burdenobject

burden.multipliernumber

burden.appliedboolean

by_serviceobject[]

by_service[].labelstring

by_service[].jobsnumber

by_service[].excludednumber

by_service[].revenuenumber

by_service[].profitnumber

by_service[].margin_pctnumber | null

by_installerobject[]

by_installer[].labelstring

by_installer[].jobsnumber

by_installer[].excludednumber

by_installer[].revenuenumber

by_installer[].profitnumber

by_installer[].margin_pctnumber | 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.

curl
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"}'

TypeScript (fetch)
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;

Python (requests)
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"]

MCP tools/call — https://www.servicevin.com/api/mcp
{
  "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.

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.