Reports & Digests
Read the numbers behind the shop — sales, payments, tax, commission, parts usage and the daily and weekly Smart Digest — with a read-only key.
Read this before you quote a number
- Money units differ by report. The Money chip on each endpoint says whether its fields are integer cents or decimal dollars. Do not assume cents.
- Dates are shop-local
YYYY-MM-DD, inclusive at both ends, in the shop’s timezone. - Cached reports come from stored per-day rows kept current as invoices and payments change; they do not compute today live. If an answer must be exact, say which report it came from and its date range.
- If a response carries
truncated: truethe result was capped; narrow the range instead of presenting it as complete.
Reports
/report-aggregates/daily
Get raw daily report aggregates
Returns the stored per-day report rows for a date range, exactly as the platform keeps them: revenue by category, cost of sales, taxes, payments by method, invoice counts, customers and per-technician stats for each shop-local day. Use it when you need to build your own totals; the other /report-aggregates endpoints are summaries built from these rows. Days with no activity have no row and are omitted. There is no pagination: every row in the range comes back in one response. Both dates are required. Dates are shop-local calendar days (the shop's time zone from Settings; America/Chicago if none is set), written YYYY-MM-DD and inclusive at both ends. Validation: each date must be a real calendar day in YYYY-MM-DD form, must fall between 2000-01-01 and 2100-12-31, and startDate must be on or before endDate. Freshness (cached mode): the figures come from stored per-day rows that are updated as invoices, counter sales and payments change, and an overnight job (07:00 UTC, covering the last 45 days) recomputes them to correct any drift. This endpoint does not add a live calculation for today: today's numbers are whatever the stored row holds, so use mode=live to force a fresh recompute. All money fields end in Cents and are integer cents. Each row's technicianStats omits commissionCents and commissionRatePercent unless the caller holds reports:profit or attendance:financials.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate * | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. |
| endDate * | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. |
curl "https://api.bigshopai.com/v1/report-aggregates/daily?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| aggregates[] * | object[] | Stored daily rows in ascending date order. No cursor: every row in the range is returned in one response. |
| aggregates[].tenantId | string | Shop (tenant) id. |
| aggregates[].date * | string date | Shop-local calendar day this row covers. |
| aggregates[].laborRevenueCents | integer | Labor revenue on invoices dated this day. Integer cents. |
| aggregates[].partsRevenueCents | integer | Parts revenue (work-order parts plus counter-sale parts). Integer cents. |
| aggregates[].tiresRevenueCents | integer | Tire revenue. Integer cents. |
| aggregates[].subcontractsCents | integer | Subcontract / sublet revenue. Integer cents. |
| aggregates[].shopSuppliesCents | integer | Shop supplies / materials revenue. Integer cents. |
| aggregates[].feesCents | integer | Fee revenue (excludes EPA and catalog service items). Integer cents. |
| aggregates[].epaCents | integer | EPA / environmental fee, its own bucket since 2026-07. Absent on older rows (read as 0). Integer cents. |
| aggregates[].serviceItemsCents | integer | Catalog service items bucket. Absent on older rows (read as 0). Integer cents. |
| aggregates[].discountsCents | integer | Discounts applied. Integer cents. |
| aggregates[].taxesCents | integer | Sales tax billed on the day's invoices (work orders plus counter sales). Integer cents. |
| aggregates[].totalRevenueCents * | integer | Invoice grand total for the day (work orders plus counter sales). Card surcharge is NOT included. Integer cents. |
| aggregates[].laborCostCents | integer | Labor cost of sales. Absent on rows written before cost enrichment (read as 0). Integer cents. |
| aggregates[].partsCostCents | integer | Parts cost of sales. Absent on older rows. Integer cents. |
| aggregates[].tiresCostCents | integer | Tire cost of sales. Absent on older rows. Integer cents. |
| aggregates[].materialsCostCents | integer | Shop-supplies / materials cost. Absent on older rows. Integer cents. |
| aggregates[].subcontractsCostCents | integer | Subcontract cost. Absent on older rows. Integer cents. |
| aggregates[].surchargeCents | integer | Card surcharge collected on the day's invoices (pass-through, outside totalRevenueCents). Absent on older rows. Integer cents. |
| aggregates[].paidSurchargeCents | integer | Card surcharge on fully paid invoices. Absent on older rows. Integer cents. |
| aggregates[].salesOrderRevenueCents | integer | Counter-sale (sales order) revenue. A SUBSET of totalRevenueCents, and its lines are also inside the category buckets above. Integer cents. |
| aggregates[].salesOrderTaxesCents | integer | Counter-sale portion of taxesCents. Integer cents. |
| aggregates[].paidSalesOrderRevenueCents | integer | Counter-sale revenue on paid sales orders. Integer cents. |
| aggregates[].salesOrderCostCents | integer | Counter-sale cost of goods. Absent on older rows. Integer cents. |
| aggregates[].unitemizedRevenueCents | integer | Invoiced revenue that produced no category line at all (a total with nothing itemized). Inside totalRevenueCents but outside the category buckets. Integer cents. |
| aggregates[].paidLaborRevenueCents | integer | Labor revenue on fully paid invoices. Integer cents. |
| aggregates[].paidPartsRevenueCents | integer | Parts revenue on fully paid invoices. Integer cents. |
| aggregates[].paidTiresRevenueCents | integer | Tire revenue on fully paid invoices. Integer cents. |
| aggregates[].paidSubcontractsCents | integer | Subcontract revenue on fully paid invoices. Integer cents. |
| aggregates[].paidShopSuppliesCents | integer | Shop supplies revenue on fully paid invoices. Integer cents. |
| aggregates[].paidFeesCents | integer | Fee revenue on fully paid invoices. Integer cents. |
| aggregates[].paidEpaCents | integer | EPA fee on fully paid invoices. Absent on older rows. Integer cents. |
| aggregates[].paidServiceItemsCents | integer | Service items on fully paid invoices. Absent on older rows. Integer cents. |
| aggregates[].paidDiscountsCents | integer | Discounts on fully paid invoices. Integer cents. |
| aggregates[].paidTaxesCents | integer | Tax on fully paid invoices. Integer cents. |
| aggregates[].paidTotalRevenueCents | integer | Grand total of fully paid invoices. Integer cents. |
| aggregates[].paymentTotalCents * | integer | Payments received on this day, net of refunds and voids. Integer cents. |
| aggregates[].laborHoursTotal | number | Total billed labor hours on invoiced orders that day, including lines with no technician named; declined work excluded. Absent on older rows. Differs from the sum of technicianStats[].laborHours, which counts only hours attributed to a named technician. |
| aggregates[].invoiceCount * | integer | Invoices dated this day (invoiced work orders plus counter sales). |
| aggregates[].paidInvoiceCount | integer | Of those, fully paid. |
| aggregates[].unpaidInvoiceCount | integer | Of those, not fully paid. |
| aggregates[].paymentCount | integer | Payment transactions received that day (refund rows and voided payments are not counted). |
| aggregates[].paymentsByMethod | object | Net payments by method, in integer cents. Keys are the payment-method labels as recorded (free-form, e.g. Cash, Check, Credit Card; Other when none was recorded). |
| aggregates[].uniqueCustomerCount | integer | Distinct customers on the day's invoices and payments. Always exact. |
| aggregates[].customerIds[] | string[] | Persisted customer id list. Capped to keep the row under the storage limit; see customerIdsTruncated. |
| aggregates[].customerIdsTruncated | boolean | True when customerIds was capped and is not exhaustive. |
| aggregates[].uniqueVehicleCount | integer | Distinct vehicles on the day's invoices. Absent on older rows. |
| aggregates[].vehicleIds[] | string[] | |
| aggregates[].technicianStats | object | Per-technician stats for the day, keyed by technician (user) id. |
| aggregates[].updatedAt | string date-time | When the row was last written. |
| count * | integer | Number of rows returned. |
| period * | object | |
| period.startDate * | string date | First day of the range (shop-local, inclusive). |
| period.endDate * | string date | Last day of the range (shop-local, inclusive). |
Errors
400— Either an invalid date range (error 'Invalid date range' with a message) or a missing parameter (error 'startDate and endDate are required', no message).403— Caller lacks the required permission.500— Unexpected error.
/report-aggregates/sales-summary
Get sales summary
Answers 'how much did the shop invoice and collect over this period, and where did it come from?' Returns revenue split by labor, parts, tires, subcontracts, shop supplies, fees, EPA and service items, with discounts, tax and card surcharge, for all invoices and for fully paid invoices only, plus payments received (by method), unique customers, average repair order, counter-sale revenue, and daily trend series. Work orders and counter sales are both included. Invoices count on their invoiced date and payments on the date received. Dates are shop-local calendar days (the shop's time zone from Settings; America/Chicago if none is set), written YYYY-MM-DD and inclusive at both ends. Validation: each date must be a real calendar day in YYYY-MM-DD form, must fall between 2000-01-01 and 2100-12-31, and startDate must be on or before endDate. When omitted, startDate defaults to the first day of the current month and endDate to today, both in shop time. Freshness (cached mode): the figures come from stored per-day rows that are updated as invoices, counter sales and payments change, and an overnight job (07:00 UTC, covering the last 45 days) recomputes them to correct any drift. This endpoint does not add a live calculation for today: today's numbers are whatever the stored row holds, so use mode=live to force a fresh recompute. Money fields are DOLLARS (decimal numbers), not cents; they are cents divided by 100 and not rounded, so round to 2 decimals for display. No cursor pagination: trendData is limited to the last 30 days of the range (trendDataTruncated says when it was clipped) and trendDaily to 3000 days (trendDailyTruncated).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to the first day of the current month in shop time. |
| endDate | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to today in shop time. |
| mode | query | "cached" | "live" default "cached" | cached (default) reads the stored per-day aggregates. live recomputes from raw invoices and payments for the range without writing anything; it is slower (its payment lookup reads the shop's whole payment history, whatever the range). Any other value is treated as cached. |
curl "https://api.bigshopai.com/v1/report-aggregates/sales-summary?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| summary * | object | |
| summary.period * | object | |
| summary.period.startDate * | string date | First day of the range (shop-local, inclusive). |
| summary.period.endDate * | string date | Last day of the range (shop-local, inclusive). |
| summary.allInvoices * | object | Totals across every invoice dated in the range. Money fields are DOLLARS. |
| summary.allInvoices.laborRevenue | number | Labor revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.partsRevenue | number | Parts revenue (includes counter-sale parts). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.tiresRevenue | number | Tire revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.subcontracts | number | Subcontract revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.shopSupplies | number | Shop supplies revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.fees | number | Fee revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.epa | number | EPA / environmental fee. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.serviceItems | number | Catalog service items. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.subtotal | number | Sum of the eight category lines above. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.discounts | number | Discounts. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.postDiscountSubtotal | number | subtotal minus discounts. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.taxes | number | Sales tax. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.surcharge | number | Card surcharge collected (pass-through; not included in total). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.total | number | Invoice grand total (work orders plus counter sales). Card surcharge is not included. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.invoiceCount | integer | Number of invoices. |
| summary.allInvoices.laborCost | number | Labor cost of sales. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.partsCost | number | Parts cost of sales. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.tiresCost | number | Tire cost of sales. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.subcontractsCost | number | Subcontract cost of sales. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.allInvoices.shopSuppliesCost | number | Shop supplies cost (the day's materials cost). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices * | object | Same breakdown restricted to fully paid invoices. Money fields are DOLLARS. |
| summary.paidInvoices.laborRevenue | number | Labor revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.partsRevenue | number | Parts revenue (includes counter-sale parts). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.tiresRevenue | number | Tire revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.subcontracts | number | Subcontract revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.shopSupplies | number | Shop supplies revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.fees | number | Fee revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.epa | number | EPA / environmental fee. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.serviceItems | number | Catalog service items. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.subtotal | number | Sum of the eight category lines above. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.discounts | number | Discounts. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.postDiscountSubtotal | number | subtotal minus discounts. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.taxes | number | Sales tax. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.surcharge | number | Card surcharge collected (pass-through; not included in total). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.total | number | Invoice grand total (work orders plus counter sales). Card surcharge is not included. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.paidInvoices.invoiceCount | integer | Number of invoices. |
| summary.payments * | object | |
| summary.payments.total | number | Payments received in the range, net of refunds and voids. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.payments.count | integer | Payment transactions. |
| summary.payments.byMethod | object | Net payments by method label. |
| summary.uniqueCustomers * | integer | Distinct customers across the range (union of the per-day customer id lists). |
| summary.salesOrderRevenue | number | Counter-sale revenue. It is inside allInvoices.total AND inside the category lines; do not add it on top. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.unitemizedRevenue | number | Revenue on invoices that produced no category line (total with nothing itemized). Inside total, outside the category lines. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.averageRepairOrder * | number | allInvoices.total divided by invoiceCount; 0 when none. Not rounded to cents. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendData[] * | object[] | Daily series for the LAST 30 days of the range, zero-filled for days with no activity. |
| summary.trendData[].date | string date | Shop-local day. |
| summary.trendData[].invoiced | number | Invoice total that day. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendData[].payments | number | Payments received that day (net of refunds and voids). Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendData[].customers | integer | Distinct customers that day. |
| summary.trendDataTruncated * | boolean | True when the range is longer than 30 days and trendData holds only the most recent 30. |
| summary.trendDataDays * | integer | Number of points in trendData. |
| summary.requestedRangeDays * | integer | Days in the requested range. |
| summary.trendDaily[] * | object[] | Rich per-day series across the WHOLE range, days with data only (no zero-fill), ascending. |
| summary.trendDaily[].date | string date | Shop-local day. |
| summary.trendDaily[].invoiced | number | Invoice grand total that day. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].payments | number | Payments received that day. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].profit | number | Invoice total minus labor, parts and tire cost only. The total includes sales tax and excludes materials and subcontract cost, so this mirrors the app's trend chart rather than accounting profit. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].customers | integer | Distinct customers. |
| summary.trendDaily[].orders | integer | Invoice count. |
| summary.trendDaily[].paidInvoices | integer | Fully paid invoice count. |
| summary.trendDaily[].paidRevenue | number | Grand total of fully paid invoices. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].laborRevenue | number | Labor revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].laborProfit | number | Labor revenue minus labor cost. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].partsRevenue | number | Parts revenue with tires folded in. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].partsProfit | number | Parts-plus-tire revenue minus parts-plus-tire cost. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDaily[].laborHours | number | Billed labor hours summed from technician stats, rounded to 0.1. |
| summary.trendDaily[].billableHours | number | Same value as laborHours. |
| summary.trendDaily[].avgPerOrder | number | Invoice total divided by invoice count; 0 when there are no invoices. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.trendDailyTruncated * | boolean | True when more than 3000 days of data existed and only the most recent 3000 are returned. |
| dataMode * | "cached" | "live" | Which source produced the numbers. |
Errors
400— Invalid date range.messageis one of: 'Dates must be real calendar days, written YYYY-MM-DD.', 'Dates must fall between 2000 and 2100.', 'The start date must be on or before the end date.'403— Caller lacks the required permission.500— Unexpected error.
/report-aggregates/payments
Get payments summary
Answers 'how much money came in over this period, by what method, and on which days?' Returns total payments received, the number of payment transactions, totals by payment method, and a per-day list. Amounts are net of refunds and voided payments; work-order and counter-sale payments are both included, each on the day it was received. Dates are shop-local calendar days (the shop's time zone from Settings; America/Chicago if none is set), written YYYY-MM-DD and inclusive at both ends. Validation: each date must be a real calendar day in YYYY-MM-DD form, must fall between 2000-01-01 and 2100-12-31, and startDate must be on or before endDate. When omitted, startDate defaults to the first day of the current month and endDate to today, both in shop time. Freshness (cached mode): the figures come from stored per-day rows that are updated as invoices, counter sales and payments change, and an overnight job (07:00 UTC, covering the last 45 days) recomputes them to correct any drift. This endpoint does not add a live calculation for today: today's numbers are whatever the stored row holds, so use mode=live to force a fresh recompute. Money fields are DOLLARS (decimal numbers), not cents. dailyTotals lists only days that have a stored row (no zero-fill).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to the first day of the current month in shop time. |
| endDate | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to today in shop time. |
| mode | query | "cached" | "live" default "cached" | cached (default) reads the stored per-day aggregates. live recomputes from raw invoices and payments for the range without writing anything; it is slower (its payment lookup reads the shop's whole payment history, whatever the range). Any other value is treated as cached. |
curl "https://api.bigshopai.com/v1/report-aggregates/payments?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| summary * | object | |
| summary.period * | object | |
| summary.period.startDate * | string date | First day of the range (shop-local, inclusive). |
| summary.period.endDate * | string date | Last day of the range (shop-local, inclusive). |
| summary.total * | number | Payments received in the range, net of refunds and voids. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.count * | integer | Payment transactions (refund rows and voided payments not counted). |
| summary.byMethod * | object | Net payments by method label as recorded (free-form). |
| summary.dailyTotals[] * | object[] | One entry per day that has a stored row, ascending. Days with no stored row are omitted. |
| summary.dailyTotals[].date * | string date | Shop-local day the payments were received. |
| summary.dailyTotals[].total * | number | Net payments that day. Dollars as a decimal number (cents divided by 100, not rounded). |
| summary.dailyTotals[].count * | integer | Payment transactions that day. |
| dataMode * | "cached" | "live" | Which source produced the numbers. |
Errors
400— Invalid date range.messageis one of: 'Dates must be real calendar days, written YYYY-MM-DD.', 'Dates must fall between 2000 and 2100.', 'The start date must be on or before the end date.'403— Caller lacks the required permission.500— Unexpected error.
/report-aggregates/tax-collected
Get sales tax collected
Answers 'how much sales tax did the shop collect, at which rates, and what was taxable versus exempt?' Returns, separately for work orders and for counter sales, taxable sales, tax billed, tax received, outstanding tax, a breakdown per tax rate with a drill-down list of the orders behind each rate, a non-taxable section (including resale-certificate and exempt-organization sales), and a parts / labor / subcontract / other split. basis=accrual (default) reports by invoice date: tax billed, with received and outstanding figures pro-rated by how much of each invoice has been paid. basis=cash reports by payment date: only money actually received in the window, whatever period its invoice belongs to, so nothing is outstanding. Dates are shop-local calendar days (the shop's time zone from Settings; America/Chicago if none is set), written YYYY-MM-DD and inclusive at both ends. Validation: each date must be a real calendar day in YYYY-MM-DD form, must fall between 2000-01-01 and 2100-12-31, and startDate must be on or before endDate. When omitted, startDate defaults to the first day of the current month and endDate to today, both in shop time. The figures come from a per-order tax ledger kept current as invoices and payments change. If the ledger for the requested range looks incomplete (fewer than 95% of the expected orders, or rows in an older format) the request rebuilds it first and sets builtOnFirstView to true; that request is slower, and this GET therefore writes data. This endpoint has no mode parameter (a mode value is ignored). The orders drill-down is capped at 250 rows per rate tier and for the non-taxable list; ordersTruncated says when it was clipped, and totals are always exact. Order rows include the customer name. Money fields end in Cents and are integer cents; rates are percentages.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to the first day of the current month in shop time. |
| endDate | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to today in shop time. |
| basis | query | "accrual" | "cash" default "accrual" | Accounting basis. accrual (default) = by invoice date; cash = by payment date. Case-insensitive; any other value is treated as accrual. |
curl "https://api.bigshopai.com/v1/report-aggregates/tax-collected?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| period * | object | |
| period.startDate * | string date | First day of the range (shop-local, inclusive). |
| period.endDate * | string date | Last day of the range (shop-local, inclusive). |
| basis * | "accrual" | "cash" | Accounting basis of the figures. |
| workOrder * | object | |
| workOrder.taxableSalesCents * | integer | Taxable sales across all rate tiers. Integer cents. |
| workOrder.totalTaxCollectedCents * | integer | Tax billed (accrual) / received (cash). Integer cents. |
| workOrder.totalTaxReceivedCents * | integer | Tax actually received. Integer cents. |
| workOrder.nonTaxableSalesCents * | integer | Net sales on non-taxable orders. Integer cents. |
| workOrder.partsBilledCents | integer | Parts billed. Integer cents. |
| workOrder.partsReceivedCents | integer | Parts received. Integer cents. |
| workOrder.partsOutstandingCents | integer | Parts outstanding. Integer cents. |
| workOrder.laborBilledCents | integer | Labor billed. Integer cents. |
| workOrder.laborReceivedCents | integer | Labor received. Integer cents. |
| workOrder.laborOutstandingCents | integer | Labor outstanding. Integer cents. |
| workOrder.taxBilledCents | integer | Same as totalTaxCollectedCents. Integer cents. |
| workOrder.taxReceivedCents | integer | Same as totalTaxReceivedCents. Integer cents. |
| workOrder.taxOutstandingCents | integer | Tax billed minus received, floored at 0. Always 0 on cash basis. Integer cents. |
| workOrder.effectiveTaxRatePercent * | number | null | Tax collected divided by taxable sales, in percent rounded to 2 decimals; null when there are no taxable sales. |
| workOrder.surchargeReceivedCents | integer | Cash basis only: card surcharge in the payments received (outside sales and tax). Integer cents. |
| workOrder.breakdown[] * | object[] | One bucket per distinct tax rate, ascending by rate. |
| workOrder.breakdown[].taxRatePercent * | number | Tax rate of this tier, in percent. |
| workOrder.breakdown[].taxableAmountCents * | integer | Taxable sales at this rate. Integer cents. |
| workOrder.breakdown[].taxCollectedCents * | integer | Tax billed at this rate (cash basis: received). Integer cents. |
| workOrder.breakdown[].taxReceivedCents * | integer | Tax received at this rate. Integer cents. |
| workOrder.breakdown[].taxOutstandingCents | integer | Tax billed but not received. Integer cents. |
| workOrder.breakdown[].partsBilledCents | integer | Parts billed. Integer cents. |
| workOrder.breakdown[].partsReceivedCents | integer | Parts received. Integer cents. |
| workOrder.breakdown[].laborBilledCents | integer | Labor billed. Integer cents. |
| workOrder.breakdown[].laborReceivedCents | integer | Labor received. Integer cents. |
| workOrder.breakdown[].rateInferred | boolean | Cash basis only: true when every order in the tier had its rate back-calculated. |
| workOrder.breakdown[].orderCount * | integer | Orders in the tier (exact). |
| workOrder.breakdown[].orders[] * | object[] | Drill-down rows, capped at 250 per tier. |
| workOrder.breakdown[].ordersTruncated * | boolean | True when the tier has more than 250 orders and orders is clipped. Totals are always exact. |
| workOrder.nonTaxable * | object | |
| workOrder.nonTaxable.orderCount * | integer | Orders that carried no tax (exact). |
| workOrder.nonTaxable.salesCents * | integer | Net sales on those orders. Integer cents. |
| workOrder.nonTaxable.taxExemptCount * | integer | Of those, orders flagged tax exempt. |
| workOrder.nonTaxable.laborBilledCents | integer | Labor billed on non-taxable orders. Integer cents. |
| workOrder.nonTaxable.laborReceivedCents | integer | Labor received on non-taxable orders. Integer cents. |
| workOrder.nonTaxable.resaleBilledCents | integer | Resale-certificate (CRT-61) sales, net of labor. Integer cents. |
| workOrder.nonTaxable.resaleReceivedCents | integer | Received portion. Integer cents. |
| workOrder.nonTaxable.exemptOrgBilledCents | integer | Exempt-organization sales, net of labor. Integer cents. |
| workOrder.nonTaxable.exemptOrgReceivedCents | integer | Received portion. Integer cents. |
| workOrder.nonTaxable.orders[] * | object[] | Drill-down rows, capped at 250. |
| workOrder.nonTaxable.ordersTruncated * | boolean | True when orders is clipped at 250. |
| workOrder.categories | object | Per-category taxable / non-taxable split. other is the remainder (fees, shop supplies, EPA, service items) and is always treated as non-taxable. On cash basis the subcontract split is zero (the remainder lands in other). |
| workOrder.categories.parts | object | |
| workOrder.categories.labor | object | |
| workOrder.categories.subcontract | object | |
| workOrder.categories.other | object | |
| workOrder.ordersTruncated * | boolean | True when any tier or the non-taxable list was clipped at 250 orders. |
| salesOrder * | object | |
| salesOrder.taxableSalesCents * | integer | Taxable sales across all rate tiers. Integer cents. |
| salesOrder.totalTaxCollectedCents * | integer | Tax billed (accrual) / received (cash). Integer cents. |
| salesOrder.totalTaxReceivedCents * | integer | Tax actually received. Integer cents. |
| salesOrder.nonTaxableSalesCents * | integer | Net sales on non-taxable orders. Integer cents. |
| salesOrder.partsBilledCents | integer | Parts billed. Integer cents. |
| salesOrder.partsReceivedCents | integer | Parts received. Integer cents. |
| salesOrder.partsOutstandingCents | integer | Parts outstanding. Integer cents. |
| salesOrder.laborBilledCents | integer | Labor billed. Integer cents. |
| salesOrder.laborReceivedCents | integer | Labor received. Integer cents. |
| salesOrder.laborOutstandingCents | integer | Labor outstanding. Integer cents. |
| salesOrder.taxBilledCents | integer | Same as totalTaxCollectedCents. Integer cents. |
| salesOrder.taxReceivedCents | integer | Same as totalTaxReceivedCents. Integer cents. |
| salesOrder.taxOutstandingCents | integer | Tax billed minus received, floored at 0. Always 0 on cash basis. Integer cents. |
| salesOrder.effectiveTaxRatePercent * | number | null | Tax collected divided by taxable sales, in percent rounded to 2 decimals; null when there are no taxable sales. |
| salesOrder.surchargeReceivedCents | integer | Cash basis only: card surcharge in the payments received (outside sales and tax). Integer cents. |
| salesOrder.breakdown[] * | object[] | One bucket per distinct tax rate, ascending by rate. |
| salesOrder.breakdown[].taxRatePercent * | number | Tax rate of this tier, in percent. |
| salesOrder.breakdown[].taxableAmountCents * | integer | Taxable sales at this rate. Integer cents. |
| salesOrder.breakdown[].taxCollectedCents * | integer | Tax billed at this rate (cash basis: received). Integer cents. |
| salesOrder.breakdown[].taxReceivedCents * | integer | Tax received at this rate. Integer cents. |
| salesOrder.breakdown[].taxOutstandingCents | integer | Tax billed but not received. Integer cents. |
| salesOrder.breakdown[].partsBilledCents | integer | Parts billed. Integer cents. |
| salesOrder.breakdown[].partsReceivedCents | integer | Parts received. Integer cents. |
| salesOrder.breakdown[].laborBilledCents | integer | Labor billed. Integer cents. |
| salesOrder.breakdown[].laborReceivedCents | integer | Labor received. Integer cents. |
| salesOrder.breakdown[].rateInferred | boolean | Cash basis only: true when every order in the tier had its rate back-calculated. |
| salesOrder.breakdown[].orderCount * | integer | Orders in the tier (exact). |
| salesOrder.breakdown[].orders[] * | object[] | Drill-down rows, capped at 250 per tier. |
| salesOrder.breakdown[].ordersTruncated * | boolean | True when the tier has more than 250 orders and orders is clipped. Totals are always exact. |
| salesOrder.nonTaxable * | object | |
| salesOrder.nonTaxable.orderCount * | integer | Orders that carried no tax (exact). |
| salesOrder.nonTaxable.salesCents * | integer | Net sales on those orders. Integer cents. |
| salesOrder.nonTaxable.taxExemptCount * | integer | Of those, orders flagged tax exempt. |
| salesOrder.nonTaxable.laborBilledCents | integer | Labor billed on non-taxable orders. Integer cents. |
| salesOrder.nonTaxable.laborReceivedCents | integer | Labor received on non-taxable orders. Integer cents. |
| salesOrder.nonTaxable.resaleBilledCents | integer | Resale-certificate (CRT-61) sales, net of labor. Integer cents. |
| salesOrder.nonTaxable.resaleReceivedCents | integer | Received portion. Integer cents. |
| salesOrder.nonTaxable.exemptOrgBilledCents | integer | Exempt-organization sales, net of labor. Integer cents. |
| salesOrder.nonTaxable.exemptOrgReceivedCents | integer | Received portion. Integer cents. |
| salesOrder.nonTaxable.orders[] * | object[] | Drill-down rows, capped at 250. |
| salesOrder.nonTaxable.ordersTruncated * | boolean | True when orders is clipped at 250. |
| salesOrder.categories | object | Per-category taxable / non-taxable split. other is the remainder (fees, shop supplies, EPA, service items) and is always treated as non-taxable. On cash basis the subcontract split is zero (the remainder lands in other). |
| salesOrder.categories.parts | object | |
| salesOrder.categories.labor | object | |
| salesOrder.categories.subcontract | object | |
| salesOrder.categories.other | object | |
| salesOrder.ordersTruncated * | boolean | True when any tier or the non-taxable list was clipped at 250 orders. |
| builtOnFirstView * | boolean | True when this request had to rebuild the tax ledger for the range before answering (slower than usual). |
Errors
400— Invalid date range.messageis one of: 'Dates must be real calendar days, written YYYY-MM-DD.', 'Dates must fall between 2000 and 2100.', 'The start date must be on or before the end date.'403— Caller lacks the required permission.500— Unexpected error.
/report-aggregates/commission
Get technician commission report
Answers 'what did each technician bill and earn in commission over this period?' Returns per technician the billed labor hours, labor revenue, jobs completed, commission rate and commission earned, plus shop totals, sorted by commission (highest first). Commission is taken from what was stored on each day's invoices at the time (using the technician's commission rate then); if a technician has a rate and labor revenue but stored commission of 0, it is recomputed as revenue times rate. Labor discounts do not reduce commission. Dates are shop-local calendar days (the shop's time zone from Settings; America/Chicago if none is set), written YYYY-MM-DD and inclusive at both ends. Validation: each date must be a real calendar day in YYYY-MM-DD form, must fall between 2000-01-01 and 2100-12-31, and startDate must be on or before endDate. When omitted, startDate defaults to the first day of the current month and endDate to today, both in shop time. Freshness (cached mode): the figures come from stored per-day rows that are updated as invoices, counter sales and payments change, and an overnight job (07:00 UTC, covering the last 45 days) recomputes them to correct any drift. This endpoint does not add a live calculation for today: today's numbers are whatever the stored row holds, so use mode=live to force a fresh recompute. Money fields are DOLLARS (decimal numbers), not cents; they are re-derived from cents with floating-point arithmetic, so round to 2 decimals for display. Rates are percentages. A mechanicId that matches nobody returns an empty list and zero totals, not a 404. This is pay data: callers who hold only reports:operational get 403.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to the first day of the current month in shop time. |
| endDate | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. Defaults to today in shop time. |
| mode | query | "cached" | "live" default "cached" | cached (default) reads the stored per-day aggregates. live recomputes from raw invoices and payments for the range without writing anything; it is slower (its payment lookup reads the shop's whole payment history, whatever the range). Any other value is treated as cached. |
| mechanicId | query | string | Restrict the report to one technician (user id, exact match). |
curl "https://api.bigshopai.com/v1/report-aggregates/commission?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| report * | object | |
| report.period * | object | |
| report.period.startDate * | string date | First day of the range (shop-local, inclusive). |
| report.period.endDate * | string date | Last day of the range (shop-local, inclusive). |
| report.summary * | object | |
| report.summary.totalLaborRevenue * | number | Labor revenue attributed to the listed technicians. Dollars as a decimal number (cents divided by 100, not rounded). |
| report.summary.totalCommission * | number | Sum of commission across the listed technicians. Dollars as a decimal number (cents divided by 100, not rounded). |
| report.summary.totalHours * | number | Billed labor hours across the listed technicians. |
| report.summary.totalJobs * | integer | Jobs completed across the listed technicians. |
| report.summary.averageCommissionRate * | number | totalCommission divided by totalLaborRevenue, in percent; 0 when there is no labor revenue. |
| report.mechanics[] * | object[] | Sorted by commission, highest first. |
| report.mechanics[].mechanicId * | string | Technician (user) id. |
| report.mechanics[].mechanicName * | string | Technician name; Unknown when none was recorded. |
| report.mechanics[].laborHours * | number | Billed labor hours. |
| report.mechanics[].laborRevenue * | number | Labor revenue attributed to the technician. Dollars as a decimal number (cents divided by 100, not rounded). |
| report.mechanics[].jobsCompleted * | integer | Jobs completed. |
| report.mechanics[].commissionRatePercent * | number | Most recent non-zero commission rate seen in the range, in percent. |
| report.mechanics[].commission * | number | Commission earned. If stored commission is 0 but a rate and labor revenue exist, it is recomputed as revenue times rate. Dollars as a decimal number (cents divided by 100, not rounded). |
| dataMode * | "cached" | "live" | Which source produced the numbers. |
Errors
400— Invalid date range.messageis one of: 'Dates must be real calendar days, written YYYY-MM-DD.', 'Dates must fall between 2000 and 2100.', 'The start date must be on or before the end date.'403— Caller lacks the required permission.500— Unexpected error.
/report-aggregates/part-usage
Get part usage totals
Answers 'which parts does the shop actually use, how many, and when was each last used?' Returns one entry per part with the quantity used on invoiced work orders over the last 180 days and over all recorded history, the latest unit cost, and the last-used date; it feeds inventory-health and parts-analytics reports. Parts are keyed by part number (or description when there is no part number). Only part lines on invoiced work orders count: tire and shop-supply lines and counter sales are not in these totals. Takes no date parameters. If startDate or endDate are sent they are validated by the shared date check (and can produce a 400) but are otherwise ignored. The totals are rebuilt once a night from a per-invoice usage ledger (the same 07:00 UTC job as the daily aggregates), so today's invoices do not appear until the next rebuild; updatedAt shows when the newest rows were written. There is no pagination and no limit: every part is returned in one response, so the payload grows with the size of the shop's parts history. totalCostCents is a unit cost in integer cents, not a total.
curl "https://api.bigshopai.com/v1/report-aggregates/part-usage" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| parts[] * | object[] | Every part with recorded usage, unordered. No cursor and no limit parameter. |
| parts[].key * | string | Part key: the part number, or the description when there is no part number, upper-cased and trimmed. |
| parts[].partNumber * | string | Part number; empty string when the part has none. |
| parts[].description * | string | Latest description seen. |
| parts[].category * | string | Latest category seen; Parts when none. |
| parts[].qtyUsed180 * | number | Quantity on invoiced work orders dated within the last 180 days, measured from the date of the nightly rebuild (UTC date). |
| parts[].qtyUsedAll * | number | Quantity on all invoiced work orders held in the part-usage ledger. |
| parts[].totalCostCents * | integer | Despite its name this is NOT a running total: it is the latest non-zero UNIT cost seen on an invoice (0 if none). Integer cents. |
| parts[].lastUsedDate * | string | Most recent invoice date the part was used (YYYY-MM-DD); empty string when unknown. |
| updatedAt * | string | null date-time | Latest rebuild timestamp across the rows; null when there are no rows. |
| count * | integer | Number of parts returned. |
Errors
400— Invalid date range.messageis one of: 'Dates must be real calendar days, written YYYY-MM-DD.', 'Dates must fall between 2000 and 2100.', 'The start date must be on or before the end date.'403— Caller lacks the required permission.500— Unexpected error.
/reports/customer-aging
Customer aging (accounts receivable) as of a day
Answers: who owes the shop money, and how overdue is it? Returns one row per customer with their unpaid invoice balances split into Current (not yet due), 1-30, 31-60, 61-90 and Over 90 days past due, as the books stood at the end of the chosen day in the shop's time zone.
mode=asOf (default) replays invoices and dated payments, so it works for any past day and for today (pass today's date for the current picture); generatedAt is the request time. mode=snapshot returns the end-of-day row stored by the scheduled snapshot job; if no snapshot was stored for that day you get HTTP 200 with rows: [] and meta.snapshotFound=false (not an error). Use GET /reports/customer-aging/snapshots to see which days exist.
An invoice is due 30 days after its invoice date unless the work order has its own due date. Only invoiced work orders that have a customer id are included; counter sales and invoices without a customer are not read. UNITS: every amount in rows is DOLLARS (decimal), but meta.untraceablePaidCents is cents.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| mode | query | "asOf" | "snapshot" default "asOf" | asOf replays history; snapshot reads the stored end-of-day row. Any value other than snapshot is treated as asOf. |
| asOf * | query | string date | Shop-local day, YYYY-MM-DD. Required. Must not be later than today in the shop's time zone. |
curl "https://api.bigshopai.com/v1/reports/customer-aging" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| mode * | "asOf" | "snapshot" | |
| asOf * | string date | Echo of the requested day. |
| generatedAt * | string date-time | asOf mode: request time. snapshot mode: when the stored snapshot was written (or request time when no snapshot exists). |
| rows[] * | object[] | |
| rows[].customerId * | string | |
| rows[].customer * | string | Customer name; Unknown when the work order carries none. |
| rows[].current * | number | Balance not yet past due. DOLLARS as a decimal number (not cents). |
| rows[].days30 * | number | Balance 1-30 days past due. DOLLARS as a decimal number (not cents). |
| rows[].days60 * | number | Balance 31-60 days past due. DOLLARS as a decimal number (not cents). |
| rows[].days90 * | number | Balance 61-90 days past due. DOLLARS as a decimal number (not cents). |
| rows[].over90 * | number | Balance more than 90 days past due. DOLLARS as a decimal number (not cents). |
| rows[].total * | number | Sum of the five buckets. DOLLARS as a decimal number (not cents). |
| rows[].invoiceCount * | integer | Open invoices contributing to this row. |
| rows[].invoices[] | object[] | Present in mode=asOf; ABSENT in mode=snapshot (snapshots store bucket totals only). |
| rows[].invoices[].workOrderId | string | |
| rows[].invoices[].invoiceNumber | string | invoiceNumber, else orderNumber, else the work-order id. |
| rows[].invoices[].invoiceDate | string date-time | Instant the invoice is considered issued: invoicedAt, else invoiceDate, else createdAt. |
| rows[].invoices[].dueDate | string date-time | The work order's own dueDate if set, otherwise invoice date + 30 calendar days. |
| rows[].invoices[].daysPastDue | integer | Whole shop-local calendar days past the due date, clamped to 0 (never negative in this object). |
| rows[].invoices[].totalAmount | number | Invoice total (current value, not as of the date). DOLLARS as a decimal number (not cents). |
| rows[].invoices[].paidAmount | number | Payments applied on or before the as-of day, net of refunds dated on or before it. DOLLARS as a decimal number (not cents). |
| rows[].invoices[].balance | number | totalAmount - paidAmount (always > 0 here; settled invoices are omitted). DOLLARS as a decimal number (not cents). |
| rows[].invoices[].bucket | "Current" | "1-30" | "31-60" | "61-90" | "Over 90" | |
| rows[].invoices[].vehicle | string | |
| rows[].invoices[].unitNumber | string | |
| rows[].invoices[].vin | string | |
| rows[].invoices[].archived | boolean | |
| meta * | object | |
| meta.reconstructed * | boolean | true for asOf (replayed from invoices + dated payments); false for snapshot. |
| meta.snapshotFound | boolean | snapshot mode only. false means the nightly job did not store a row for that day; rows is then []. |
| meta.truncated | boolean | snapshot mode only. true when the stored snapshot kept only the largest balances to fit the 350 KB item cap. |
| meta.untraceablePaidCents | integer | Paid amount on in-scope invoices that have a header paid total but no dated payment rows (imported/migrated invoices); treated as paid from issue date. NOTE: this one field is CENTS while rows are dollars. Integer cents. |
| meta.untraceableInvoiceCount | integer | Invoices contributing to untraceablePaidCents (asOf mode only). |
Errors
400—{"error":"asOf must be a YYYY-MM-DD date"}(missing/malformed) or{"error":"asOf cannot be in the future"}.403— Forbidden. The caller's role lacksreports:operational.500— Server error. Body is{"error": "Failed to load historical aging"}; internal detail is logged, not returned.
/reports/customer-aging/snapshots
List days that have a stored aging snapshot
Answers: for which days can I read the stored (audit-grade) receivables snapshot? Returns the dates, newest first. Use one of them as asOf with mode=snapshot on GET /reports/customer-aging.
curl "https://api.bigshopai.com/v1/reports/customer-aging/snapshots" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| dates[] * | string date[] | Days for which a stored end-of-day aging snapshot exists, newest first. |
Errors
403— Forbidden. The caller's role lacksreports:operational.500— Server error. Body is{"error": "Failed to list snapshots"}; internal detail is logged, not returned.
/reports/weekly-trend
Week-over-week revenue and profit trend
Answers: how is the shop trending week to week? Returns up to weeks weekly data points (Monday-Sunday in the shop's time zone), oldest to newest: revenue, labor and parts revenue and profit, labor hours, jobs, customers and invoice count.
These are pre-computed rollups, not live. The nightly rollup (00:00 UTC) recomputes the current and the previous week; older weeks only change on a backfill. So the current week reflects the last nightly run, not this minute.
UNITS: all money is integer cents. (A stale code comment says dollars; the payload is cents.) revenueCents includes tax; partsRevenueCents/partsProfitCents include tires.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| weeks | query | integer default 12, range 1–52 | How many most-recent weeks to return. Values above 52 are clamped to 52, below 1 to 1, and non-numeric input falls back to 12. |
curl "https://api.bigshopai.com/v1/reports/weekly-trend" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| weeklyTrend[] * | object[] | Oldest to newest. Weeks with no stored rollup row are simply absent, so fewer than weeks items can come back. |
| weeklyTrend[].weekStart * | string date | Monday of the week, shop-local. |
| weeklyTrend[].weekEnd * | string date | Sunday of the week, shop-local. |
| weeklyTrend[].revenueCents * | integer | Total invoiced revenue INCLUDING tax (net-of-tax gross + taxes). Integer cents. |
| weeklyTrend[].laborRevenueCents * | integer | Labor revenue. Integer cents. |
| weeklyTrend[].partsRevenueCents * | integer | Parts revenue PLUS tires revenue. Integer cents. |
| weeklyTrend[].laborProfitCents * | integer | Labor revenue minus labor cost. Labor cost is hours x each technician's hourly cost rate, so this embeds the shop's technician wage cost in aggregate. Integer cents. |
| weeklyTrend[].partsProfitCents * | integer | Parts profit PLUS tires profit (revenue - cost). Integer cents. |
| weeklyTrend[].laborHours * | number | Billable labor hours for the week, rounded to 2 dp. |
| weeklyTrend[].jobs * | integer | Equals invoiceCount (the rollup sets jobsCompleted = invoiceCount). |
| weeklyTrend[].customers * | integer | Distinct customers invoiced in the week. |
| weeklyTrend[].invoiceCount * | integer | Invoices in the week. |
Errors
403— Forbidden. The caller's role lacksreports:operational.500— Server error. Body is{"error": "Failed to get weekly trend"}; internal detail is logged, not returned.
Digests
/digests
List daily or weekly digests
Lists the shop's AI morning briefings, newest first, without the full text: headline, date, how it was produced, delivery status and any thumbs rating. A daily digest covers the previous completed business day in the shop's time zone and a weekly digest covers the last completed Monday-to-Sunday week (dated by its Monday). Digests exist only for shops that have the feature switched on: an hourly job writes one after the shop's send hour (5-9 am shop time, default 6), so a shop that never enabled it gets an empty list. Each digest is created once and not updated as the day goes on (regenerating overwrites it). Admin role required; API keys carry it. Pagination: none. The response holds the most recent limit digests only (1-60, default 30); there is no cursor and older digests cannot be reached through this endpoint once they fall outside the newest 60. Fetch a specific older one with GET /digests/{date}.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| period | query | "daily" | "weekly" default "daily" | weekly selects the weekly digests (keyed by the Monday of the covered week). Any other value, or omitting it, selects daily digests. |
| limit | query | integer default 30, range 1–60 | How many digests to return, newest first. Clamped to 1-60; a missing, non-numeric or 0 value gives the default of 30. |
curl "https://api.bigshopai.com/v1/digests" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| digests[] * | object[] | Newest first. |
| digests[].businessDate * | string date | Daily: the shop-local day the digest covers (the completed previous day). Weekly: the Monday of the covered week. |
| digests[].period * | "daily" | "weekly" | Cadence. |
| digests[].weekEnd * | string | null date | Weekly only: the Sunday of the covered week; null for daily. |
| digests[].headline * | string | One-line headline (<= 120 chars). |
| digests[].renderMode * | "llm" | "fallback" | llm = model-written narrative that passed number validation; fallback = deterministic template used instead. |
| digests[].status * | "generated" | "delivered" | "failed" | Lifecycle state. |
| digests[].feedbackScore * | "up" | "down" | Owner thumbs rating, if given. |
| digests[].createdAt * | string date-time | When the digest row was created. |
Errors
401— Caller's tenant could not be resolved.403— Caller is not an admin.500— Unexpected error.
/digests/{date}
Get one digest
Returns one full digest: the written briefing (headline, summary, bullets, wins, concerns, things to watch, recommended actions, callouts) and the data snapshot it was built from (revenue, labor, parts, payments, gross profit, open work, receivables and so on, each with comparisons to yesterday, last week or the prior four weeks). Every figure in the narrative is copied from a pre-formatted display string in the snapshot; the model never computes numbers, and if validation fails a deterministic template is used (renderMode = fallback). Money in the snapshot is integer cents, each with a matching display string. Keys inside narrative and snapshot are snake_case. For daily digests {date} is the covered day. For weekly digests (period=weekly) {date} must be the Monday of the covered week; any other date returns 404 because the lookup is by exact key. {date} must match YYYY-MM-DD; it is not checked for being a real calendar day, so an impossible date such as 2026-02-31 simply returns 404. A digest that does not exist, or that belongs to another shop, returns 404. Admin role required; API keys carry it. The digest is a fixed record created once; it does not change as the day goes on.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| date * | path | string date | Business date, YYYY-MM-DD. Daily: the covered day. Weekly: the Monday of the covered week. |
| period | query | "daily" | "weekly" default "daily" | weekly selects the weekly digests (keyed by the Monday of the covered week). Any other value, or omitting it, selects daily digests. |
curl "https://api.bigshopai.com/v1/digests/2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| businessDate * | string date | Daily: the shop-local day the digest covers (the completed previous day). Weekly: the Monday of the covered week. |
| period * | "daily" | "weekly" | Cadence. |
| weekEnd * | string | null date | Weekly only: the Sunday of the covered week; null for daily. |
| headline * | string | One-line headline (<= 120 chars). |
| renderMode * | "llm" | "fallback" | llm = model-written narrative that passed number validation; fallback = deterministic template used instead. |
| status * | "generated" | "delivered" | "failed" | Lifecycle state. |
| feedbackScore * | "up" | "down" | Owner thumbs rating, if given. |
| createdAt * | string date-time | When the digest row was created. |
| narrative * | Parsed narrative; null if the stored JSON could not be parsed. | |
| snapshot * | Parsed snapshot; null if the stored JSON could not be parsed. | |
| feedbackComment * | string | null | Owner's feedback comment, if any. |
| deliveredAt * | string | null date-time | When the digest was delivered in-app. |
| emailedAt * | string | null date-time | When the digest email was sent. |
Errors
401— Caller's tenant could not be resolved.403— Caller is not an admin.404— No digest for that date and period (also returned for a path that is not a YYYY-MM-DD date).500— Unexpected error.
Dashboard
/dashboard/trend-aggregates
Get daily trend aggregates
Answers 'how did invoicing and payments trend day by day over this range?' Returns one entry per shop-local day that has a stored aggregate: invoice count and revenue (total, labor, parts, tires, counter sales), the same for fully paid invoices, payments received, unique customers, and per-technician stats. It reads the same stored per-day rows as /report-aggregates/daily, converted for charting. Both dates are required. Unlike the /report-aggregates endpoints this one does NOT run the shared date validation: dates are not checked for format or range, startDate after endDate is not rejected up front, and the 2000-2100 bounds do not apply. Send real YYYY-MM-DD dates with startDate <= endDate. Freshness: reads stored daily rows, which are updated as invoices and payments change and recomputed by the overnight job (07:00 UTC, last 45 days); there is no live recalculation here, and there is no period parameter. No pagination: the endpoint issues a single database query and does not follow a continuation key, so a very wide range could be cut off at the first ~1 MB of rows with no truncated flag. Keep ranges to a few months. Money units are mixed: all top-level revenue and payment fields are DOLLARS (decimal numbers), but money inside technicianStats (laborRevenueCents, commissionCents) is still integer CENTS. technicianStats omits commissionCents and commissionRatePercent unless the caller holds reports:profit or attendance:financials.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate * | query | string date | First day of the range (inclusive), shop-local YYYY-MM-DD. Not validated by this endpoint. |
| endDate * | query | string date | Last day of the range (inclusive), shop-local YYYY-MM-DD. Not validated by this endpoint. |
curl "https://api.bigshopai.com/v1/dashboard/trend-aggregates?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| trendData[] * | object[] | One entry per day that has a stored row, ascending. Days with no stored row are omitted. |
| trendData[].date * | string date | Shop-local day. |
| trendData[].invoiceCount * | integer | Invoices that day. |
| trendData[].totalRevenue * | number | Invoice grand total. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].laborRevenue | number | Labor revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].partsRevenue | number | Parts revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].tiresRevenue | number | Tire revenue. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paidInvoiceCount | integer | Fully paid invoices. |
| trendData[].paidTotalRevenue | number | Grand total of fully paid invoices. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paidLaborRevenue | number | Labor revenue on fully paid invoices. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paidPartsRevenue | number | Parts revenue on fully paid invoices. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].salesOrderRevenue | number | Counter-sale revenue (a subset of totalRevenue). Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paidSalesOrderRevenue | number | Counter-sale revenue on paid sales orders (subset of paidTotalRevenue). Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paymentTotal * | number | Payments received that day. Dollars as a decimal number (cents divided by 100, not rounded). |
| trendData[].paymentCount | integer | Payment transactions that day. |
| trendData[].uniqueCustomerCount | integer | Distinct customers that day. |
| trendData[].technicianStats * | object | Per-technician stats keyed by technician id. UNLIKE the fields above, money inside here is still in integer CENTS (laborRevenueCents, commissionCents). |
| startDate * | string | Echo of the startDate parameter. |
| endDate * | string | Echo of the endDate parameter. |
| daysWithData * | integer | Entries with at least one invoice or payment. |
Errors
400— Either date is missing.403— Caller lacks the required permission.500— Query failed (for example startDate after endDate).
/dashboard/metrics
Get dashboard metrics
The numbers behind the shop dashboard: revenue, average job, payments received, gross profit and margin, technician utilization, a day-by-day trend, customers and vehicles served, all for a date range and compared with the previous period of the same length; plus right-now shop-floor counts (open jobs, vehicles in the shop, overdue, delayed, stale, waiting for parts), unpaid invoices, the top customers and invoices owing money, collection health, and low-stock part counts. Each group has its own freshness, and freshness in the response says how old each one is. Period figures (revenue, payments, profit, utilization, trend, served counts) come from the stored per-day rows, kept current as invoices and payments change and recomputed overnight; there is no live recalculation, and isStale is always false. Floor counters are updated as work orders change and recounted hourly (:10 past the hour). Unpaid totals are updated as invoices change and re-anchored nightly. paymentHealth and collections come from last night's receivables snapshot and cover ALL outstanding receivables, not just the range. Parts counts come from the latest inventory snapshot. Date range: pass BOTH startDate and endDate (YYYY-MM-DD, shop-local, inclusive) for a custom range. If either is missing or malformed, or startDate is after endDate, the range is silently replaced by the current month to date; there is no 400 for that. Calendar-impossible dates are not rejected either (2026-02-31 is accepted and rolls over). The only range error is more than 400 days (400 'Date range too long'). The previous period is the same number of days immediately before startDate. The shop's time zone is NOT read from settings here; it comes from the timezone parameter (default America/Chicago) and only decides what 'today' is for the default range. The stored rows themselves are already bucketed by the shop's own time zone. If the shop's history has not been backfilled (stored rows start after its first invoice) the response has building = true and metrics = null. Money units are mixed: every metric is DOLLARS (decimal numbers), except trendDaily.days[*], which is integer CENTS (fields ending in Cents). Tax: periodRevenue, avgPerJob, counterRevenue and the trend's invoicedCents include sales tax; grossProfit.revenue excludes it. The other query parameters you may see used by the app (recalculate) are ignored.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day of a custom range (inclusive), shop-local YYYY-MM-DD. Used only together with endDate; otherwise ignored. Format-checked only, not checked for being a real calendar day. |
| endDate | query | string date | Last day of a custom range (inclusive), shop-local YYYY-MM-DD. Used only together with startDate; must be on or after it or both are ignored. The range may span at most 400 days or the request fails with 400. |
| timezone | query | string default "America/Chicago" | IANA time zone name used to decide today's date for the default (month-to-date) range, and echoed in trendDaily.timezone. Default America/Chicago. Not validated: an unknown name silently falls back to UTC for the day calculation. Send the shop's time zone. |
| period | query | string default "month" | Echoed back as period in the response (default 'month'). It does not change which dates are used; use startDate and endDate. |
curl "https://api.bigshopai.com/v1/dashboard/metrics?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| building * | boolean | True when the stored daily rows do not reach back to the shop's first invoice (history not yet backfilled). metrics is then null and no card, including the floor counters, is served; the shop owner must run the history backfill. |
| coverage * | object | |
| coverage.earliestAggregateDay * | string | null date | Earliest stored daily row for the shop. |
| coverage.earliestInvoiceDay * | string | null date | Earliest realized invoice day. |
| metrics * | Dashboard cards; null when building is true. | |
| freshness * | object | Age of each underlying source. The per-metric updatedAt values are request time and say nothing about data age. |
| freshness.aggregatesThrough * | string | null date-time | Latest write time among the stored daily rows used (current and previous period). Null when none. |
| freshness.floorRecountedAt * | string | null date-time | Last hourly recount of the floor counters. |
| freshness.floorUpdatedAt * | string | null date-time | Last write to the floor/unpaid state row. |
| freshness.unpaidAsOf * | string | null date | Shop-local day of the AR snapshot that last re-anchored the unpaid totals. |
| freshness.arAsOf * | string | null date | Day of the AR snapshot behind paymentHealth and collections. |
| freshness.inventoryAsOf * | string | null date | Day of the inventory snapshot behind lowStockItems and totalParts. |
| isStale * | boolean | Always false (hard-coded). |
| lastUpdated * | string date-time | Time of this request. |
| period * | string | Echo of the period query parameter, default 'month'. Does not affect the numbers. |
Errors
400— Range longer than 400 days.403— Caller lacks dashboard:read.500— Unexpected error.
Inventory
/inventory/parts-purchasing-report
Parts purchasing ledger (opening, purchases, usage, closing)
Answers: what did the shop buy, use and hold in a period, and how did inventory value move? Default response is a shop-wide summary (opening value, purchases, usage with revenue and profit, reserved/held change, closing value, a reconciliation residual and cost changes) plus one ledger row per part, tire, fluid or shop item that had activity.
Dates are shop-local days (inclusive both ends, shop time zone default America/Chicago). All money is integer cents; quantities are units.
The same route has drill-down modes selected by query parameters (precedence: view=daily, then partId, then bucket; otherwise the list): partId returns that part's raw transactions, bucket returns transactions across parts, view=daily returns a part x day pivot. The detail modes are paginated with page/limit and report hasMore.
Opening/closing come from end-of-day inventory snapshots; where a snapshot is missing the value is null, or live (flagged openingIsLive/closingIsLive) when the window touches today.
Cost, price and profit are returned to any caller that passes the read gate; there is no per-field cost masking.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate * | query | string date | First shop-local day, YYYY-MM-DD. Required (presence only is validated; format is not). |
| endDate * | query | string date | Last shop-local day (inclusive), YYYY-MM-DD. Required. |
| search | query | string | Case-insensitive substring match on part number or part name (list, bucket and daily modes). |
| category | query | string | List mode: single category, exact case-insensitive match against each row's partCategory. Bucket/daily modes: comma-separated exact, case-sensitive values. |
| partId | query | string | Return this item's raw transactions instead of the list (an inventory part id or a material id). |
| bucket | query | "inbound" | "outbound" | "reserved" | Return transactions across parts counted in this bucket. |
| view | query | "daily" | daily returns the part x shop-day pivot. |
| page | query | integer default 1, range 1– | Detail modes only (partId/bucket). 1-based. |
| limit | query | integer default 200, range 1–500 | Detail modes only (partId/bucket). Page size, capped at 500. |
| vendor | query | string | Bucket/daily modes only. Comma-separated vendor names (case-insensitive). |
| types | query | string | Bucket/daily modes only. Comma-separated transaction types. |
| sources | query | string | Bucket/daily modes only. Comma-separated reference types (work_order, purchase_order, manual, ...). |
curl "https://api.bigshopai.com/v1/inventory/parts-purchasing-report?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Errors
400—{"error":"startDate and endDate are required (YYYY-MM-DD)"}.403— Forbidden. The caller's role lacksinventory:read (or parts:read).500— Unhandled server error:{"error":"Internal Server Error","message":"An unexpected error occurred"}.
/inventory/parts-activities
Parts activity log (every stock movement and part edit in a period)
Answers: who changed inventory, when, and why? Lists quantity movements (purchases received, adjustments, reservations, usage on work orders and counter sales, returns) and part edit/create events over a date range, newest first, with KPI stats, filter facets and the same opening/closing inventory cards as the purchasing report.
Pass groupBy to get subtotalled groups instead of rows (not paginated), or view=lifecycle to collapse each part's events per order. Dates are shop-local days (inclusive). All money is integer cents.
Pagination is page/limit (not a cursor). Each source stream is read up to 5000 rows; when that cap is hit truncated is true and the list and stats are partial.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate * | query | string date | First shop-local day, YYYY-MM-DD. Required. |
| endDate * | query | string date | Last shop-local day (inclusive). Required. |
| partId | query | string | Single-part view (part or material id). Looks that item up directly. |
| activityType | query | string | Comma-separated activity types (case-insensitive). Valid values appear in facets.types. |
| userId | query | string | Only events performed by this user id. |
| source | query | string | Comma-separated reference types, e.g. work_order, manual, purchase_order, ai_upload, release_queue (case-insensitive). |
| vendor | query | string | Comma-separated vendor names (case-insensitive). |
| category | query | string | Comma-separated part categories (case-insensitive). |
| invoiceNumber | query | string | Exact vendor invoice number. |
| poNumber | query | string | Exact PO number. |
| search | query | string | Case-insensitive substring over part number/name, work order number, customer, invoice #, PO #, vendor and user name. |
| page | query | integer default 1, range 1– | 1-based page. |
| limit | query | integer default 50, range 1–200 | Page size, clamped to 1-200. |
| groupBy | query | "vendor" | "category" | "activityType" | "source" | "performedBy" | "invoice" | "po" | Return subtotalled groups instead of rows. Unknown values are ignored. |
| thenBy | query | "vendor" | "category" | "activityType" | "source" | "performedBy" | "invoice" | "po" | Second grouping level (with groupBy). |
| view | query | "lifecycle" | lifecycle: one row per part per order, with the events nested in children. Ignored with groupBy. |
curl "https://api.bigshopai.com/v1/inventory/parts-activities?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Errors
400—{"error":"startDate and endDate are required (YYYY-MM-DD)"}.403— Forbidden. The caller's role lacksinventory:read (or parts:read).500— Unhandled server error:{"error":"Internal Server Error","message":"An unexpected error occurred"}.
Vendors
/vendors/ap-report
Vendor accounts payable report (what the shop owes vendors)
Answers: how much does the shop owe its vendors, how overdue is it, and what was bought, returned and held in core deposits? One call returns vendor invoices with net-30 aging as of TODAY (shop-local), purchase orders, part returns, core-deposit lines and core write-offs.
Computed live on every request from purchase orders, ad-hoc vendor invoices, vendors and core write-offs. A PO counts as an invoice only if it has a vendor invoice number. All money is integer cents.
Lists are newest-first and cut at 5000 rows each; truncated is an ARRAY of the source names that hit a cap (empty = complete).
curl "https://api.bigshopai.com/v1/vendors/ap-report" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| asOf * | string date | Today in the shop's time zone. Always today: the endpoint takes NO date parameter. |
| timeZone * | string | IANA zone used for all day labels (default America/Chicago). |
| vendors[] * | object[] | |
| vendors[].id * | string | |
| vendors[].name * | string | |
| vendors[].contactPhone | string | |
| vendors[].contactEmail | string | |
| vendors[].accountNumber | string | |
| invoices[] * | object[] | Newest invoice date first. Capped at 5000 rows (see truncated). |
| invoices[].id * | string | po-<poId> for invoices billed through a purchase order, adhoc-<id> for PO-less vendor invoices. |
| invoices[].vendorId * | string | |
| invoices[].vendorName * | string | |
| invoices[].invoiceNumber * | string | Vendor invoice number(s); several are joined with , . |
| invoices[].poId | string | |
| invoices[].poNumber | string | |
| invoices[].date * | string date | Shop-local invoice day. |
| invoices[].dueDate * | string date | Always date + 30 days (fixed net-30 in code). |
| invoices[].daysPastDue * | integer | Days past dueDate as of today (shop-local). NEGATIVE when not yet due (not clamped, unlike the customer aging endpoint). |
| invoices[].agingBucket * | "current" | "days30" | "days60" | "days90" | "over90" | current = not yet due; days30 = 1-30 past due; days60 = 31-60; days90 = 61-90; over90 = more than 90. |
| invoices[].totalCents * | integer | Integer cents. |
| invoices[].paidCents * | integer | Equals totalCents when paid, else 0 (no partial payments at this level). Integer cents. |
| invoices[].balanceCents * | integer | Integer cents. |
| invoices[].status * | "paid" | "unpaid" | |
| invoices[].paidOn | string date | Shop-local day the bill was marked paid, when recorded. |
| invoices[].kind * | "po" | "adhoc" | |
| invoices[].fileName | string | |
| invoices[].itemCount | integer | |
| invoices[].notes | string | |
| purchaseOrders[] * | object[] | Newest first. Capped at 5000 rows. |
| purchaseOrders[].id * | string | |
| purchaseOrders[].number * | string | |
| purchaseOrders[].vendorId * | string | |
| purchaseOrders[].vendorName * | string | |
| purchaseOrders[].status * | string | |
| purchaseOrders[].isPaid * | boolean | |
| purchaseOrders[].totalCostCents * | integer | Integer cents. |
| purchaseOrders[].partCount | integer | |
| purchaseOrders[].receivedPartCount | integer | |
| purchaseOrders[].invoiceNumbers[] | string[] | |
| purchaseOrders[].createdAt | string date-time | |
| purchaseOrders[].day | string date | Shop-local day of createdAt. |
| purchaseOrders[].orderedDate | string | |
| purchaseOrders[].receivedDate | string | |
| purchaseOrders[].fulfilledDate | string | |
| returns[] * | object[] | Capped at 5000 rows. |
| returns[].id * | string | |
| returns[].vendorId * | string | |
| returns[].vendorName * | string | |
| returns[].purchaseOrderId | string | |
| returns[].purchaseOrderNumber | string | |
| returns[].partName * | string | |
| returns[].partNumber | string | |
| returns[].quantity * | number | |
| returns[].reason | string | |
| returns[].status * | "pending" | "approved" | "completed" | "rejected" | |
| returns[].refundAmountCents * | integer | Integer cents. |
| returns[].createdAt | string | |
| returns[].day | string date | |
| returns[].notes | string | |
| cores[] * | object[] | Capped at 5000 rows. |
| cores[].id | string | |
| cores[].vendorId | string | |
| cores[].vendorName | string | |
| cores[].purchaseOrderId | string | |
| cores[].purchaseOrderNumber | string | |
| cores[].partNumber | string | |
| cores[].partName | string | |
| cores[].depositDay | string | Shop-local day the deposit was received; '' when the line holds no deposit. |
| cores[].creditDay | string | Shop-local day the credit was recorded; '' when none. |
| cores[].depositUnits | number | |
| cores[].depositCents | integer | Integer cents. |
| cores[].creditUnits | number | |
| cores[].creditCents | integer | Integer cents. |
| cores[].openUnits | number | Deposit units no credit has matched yet. |
| cores[].openCents | integer | Integer cents. |
| cores[].unmatchedCreditUnits | number | |
| cores[].unmatchedCreditCents | integer | Integer cents. |
| cores[].writtenOffUnits | number | |
| cores[].writtenOffCents | integer | Integer cents. |
| coreWriteOffs[] * | object[] | |
| coreWriteOffs[].id | string | |
| coreWriteOffs[].vendorId | string | |
| coreWriteOffs[].vendorName | string | |
| coreWriteOffs[].partNumber | string | |
| coreWriteOffs[].units | number | |
| coreWriteOffs[].amountCents | integer | What the closed deposits cost when written off. Integer cents. |
| coreWriteOffs[].reason | string | |
| coreWriteOffs[].createdBy | string | |
| coreWriteOffs[].createdAt | string date-time | |
| coreWriteOffs[].day | string date | |
| truncated[] * | string[] | NOT a boolean. Names of the sources that hit a read/row cap (any of: purchase orders, vendor invoices, vendors, core write-offs, invoices, part returns, core lines). Empty array = complete. When non-empty the figures are a floor. |
Errors
403— Forbidden. The caller's role lacksvendors:read.500— Unhandled server error:{"error":"Internal Server Error","message":"An unexpected error occurred"}.
Customers
/customers/{customerId}/unpaid-invoices
A customer's open (unpaid) invoices
Answers: what does this customer currently owe, invoice by invoice? Lists the customer's invoiced work orders and counter sales that still have a balance, oldest first. Archived invoices that are still unpaid are included. Balances are computed from the Payments table (net of refunds), with declined services excluded, so they match what a statement would show.
Money is returned twice: integer cents (*Cents, canonical) and dollar decimals (total, paid, balance). Dates for startDate/endDate are shop-local, compared against the invoice date (invoiced date, else created); invoices with no date are included.
If the customer has more than 1500 open invoices only the 1500 most recent are evaluated and truncated is true; narrow with startDate/endDate. An unknown or other-shop customer id returns 200 with an empty list, not 404.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| customerId * | path | string | Customer id. |
| startDate | query | string date | Only invoices dated on/after this shop-local day (YYYY-MM-DD, inclusive). |
| endDate | query | string date | Only invoices dated on/before this shop-local day (inclusive). |
curl "https://api.bigshopai.com/v1/customers/<customerId>/unpaid-invoices?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| invoices[] * | object[] | Oldest invoiced date first. |
| invoices[].id * | string | Work order or counter-sale id. |
| invoices[].orderNumber * | string | |
| invoices[].orderName | string | Title, truncated to 60 chars. |
| invoices[].description | string | |
| invoices[].vehicle | string | |
| invoices[].vehicleId | string | |
| invoices[].vehicleVin | string | |
| invoices[].vehicleUnitNumber | string | |
| invoices[].poNumber | string | |
| invoices[].customerName | string | |
| invoices[].date | string | Creation timestamp. |
| invoices[].invoicedDate | string | |
| invoices[].dueDate | string | |
| invoices[].totalAmountCents * | integer | Active total (declined services excluded; shop-supplies cap applied). Integer cents. |
| invoices[].paidAmountCents * | integer | Net of refunds, computed from the Payments table (not the work-order header). Integer cents. |
| invoices[].balanceCents * | integer | Always > 0 in this list. Integer cents. |
| invoices[].total | number | Dollar twin of totalAmountCents. DOLLARS as a decimal number (not cents). |
| invoices[].paid | number | Dollar twin of paidAmountCents. DOLLARS as a decimal number (not cents). |
| invoices[].balance | number | Dollar twin of balanceCents. DOLLARS as a decimal number (not cents). |
| invoices[].status | string | |
| invoices[].archived | boolean | Archived but still unpaid invoices ARE included. |
| invoices[].sourceType * | "wo" | "so" | wo = work order invoice, so = counter sale. |
| truncated * | boolean | true when the customer had more than 1500 open invoices and only the 1500 most recent were evaluated; narrow with startDate/endDate. |
Errors
403— Forbidden. The caller's role lackscustomers:read.500—{"error":"Failed to list unpaid invoices"}.
/customers/{customerId}/statements
Statements created for a customer
Answers: what statements has the shop built for this customer and where do they stand? Each statement bundles several invoices; every read re-resolves the bundled invoices live (total, paid, balance and the payments applied), so numbers are current rather than as-created. Newest first.
Money is returned as integer cents plus dollar twins (totalAmount, paidAmount, totalDue). status is recomputed to Paid when nothing is due or Partial when something was paid, otherwise the stored status (Draft when created). There is no pagination and no truncation flag. Payment rows include card brand/last4, check numbers and bank names.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| customerId * | path | string | Customer id. |
curl "https://api.bigshopai.com/v1/customers/<customerId>/statements" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| statements[] * | object[] | Newest first. No pagination or truncation flag. |
| statements[].id * | string | |
| statements[].customerId * | string | |
| statements[].statementNumber * | string | ST- prefixed. |
| statements[].invoiceIds[] * | string[] | |
| statements[].totalAmountCents * | integer | Sum of invoice totals, recomputed live on every read. Integer cents. |
| statements[].paidAmountCents * | integer | Recomputed live. Integer cents. |
| statements[].totalDueCents * | integer | Sum of POSITIVE invoice balances only (an overpaid invoice does not offset another's due). Integer cents. |
| statements[].creditAppliedCents | integer | Integer cents. |
| statements[].totalAmount | number | DOLLARS as a decimal number (not cents). |
| statements[].paidAmount | number | DOLLARS as a decimal number (not cents). |
| statements[].totalDue | number | DOLLARS as a decimal number (not cents). |
| statements[].status * | string | Draft when created; recomputed on read to Paid (nothing due) or Partial (something paid). A statement that has been sent may carry other values - not enumerated. |
| statements[].includeInvoices | boolean | |
| statements[].startDate | string | |
| statements[].endDate | string | |
| statements[].notes | string | |
| statements[].invoices[] | object[] | Live-resolved at read time, not the stored copy. Only present/refreshed when invoiceIds is non-empty. |
| statements[].invoices[].id | string | |
| statements[].invoices[].orderNumber | string | |
| statements[].invoices[].orderName | string | |
| statements[].invoices[].description | string | |
| statements[].invoices[].vehicle | string | |
| statements[].invoices[].vehicleId | string | |
| statements[].invoices[].vehicleVin | string | |
| statements[].invoices[].vehicleUnitNumber | string | |
| statements[].invoices[].customerName | string | |
| statements[].invoices[].date | string | |
| statements[].invoices[].invoicedDate | string | |
| statements[].invoices[].dueDate | string | |
| statements[].invoices[].totalAmountCents | integer | Integer cents. |
| statements[].invoices[].paidAmountCents | integer | Integer cents. |
| statements[].invoices[].balanceCents | integer | Can be NEGATIVE (overpayment). Integer cents. |
| statements[].invoices[].creditedFromOverpaymentCents | integer | Integer cents. |
| statements[].invoices[].creditAppliedCents | integer | Portion paid with store credit. Integer cents. |
| statements[].invoices[].total | number | DOLLARS as a decimal number (not cents). |
| statements[].invoices[].paid | number | DOLLARS as a decimal number (not cents). |
| statements[].invoices[].balance | number | DOLLARS as a decimal number (not cents). |
| statements[].invoices[].status | string | |
| statements[].invoices[].sourceType | "wo" | "so" | |
| statements[].invoices[].payments[] | object[] | |
| statements[].payments[] | object[] | |
| statements[].payments[].id | string | |
| statements[].payments[].invoiceId | string | |
| statements[].payments[].orderNumber | string | |
| statements[].payments[].date | string | |
| statements[].payments[].method | string | |
| statements[].payments[].amountCents | integer | Integer cents. |
| statements[].payments[].amount | number | Dollar twin of amountCents. DOLLARS as a decimal number (not cents). |
| statements[].payments[].reference | string | |
| statements[].payments[].checkNumber | string | |
| statements[].payments[].bankName | string | |
| statements[].payments[].referenceNumber | string | |
| statements[].payments[].cardLast4 | string | |
| statements[].payments[].cardBrand | string | |
| statements[].payments[].notes | string | |
| statements[].payments[].isRefund | boolean | |
| statements[].payments[].status | string | |
| statements[].payments[].bulkPaymentId | string | |
| statements[].payments[].bulkPaymentKind | string | |
| statements[].payments[].orderNumbers[] | string[] | |
| statements[].sentAt | string | Set when the statement was sent to the customer. |
| statements[].sentVia | string | |
| statements[].createdAt | string date-time | |
| statements[].updatedAt | string date-time |
Errors
403— Forbidden: lackscustomers:read, or the role is explicitly deniedcustomers:financials. Body is PERMISSION_DENIED.404—{"error":"Customer not found"}- unknown id OR a customer in another shop (cross-tenant reads return 404).500—{"error":"Failed to list statements"}.
Payments
/payments
List payments received (work orders and counter sales)
Answers: what money came in, and when? Returns payment records for the whole shop (work-order invoices and counter sales merged), optionally restricted to a date range. Amounts are integer cents.
PAGING: cursor-based, but with cursor/nextCursor (opaque base64), NOT lastEvaluatedKey. To fetch everything in a period, pass startDate+endDate and keep passing the returned nextCursor until hasMore is false. limit is a minimum target rather than a hard maximum: pages stop at a database page boundary, so a page can return more than limit rows. The payment day used for the date filter is date, else paymentDate, else createdAt (shop-local when timezone is given, otherwise the UTC date prefix).
Without a date range the rows are NOT the newest payments: work-order payments are stored under PAYMENT#<uuid>, so the first page is an arbitrary slice. Always pass a date range for reporting.
Rows are the stored payment records, including payer details such as Zelle contact and check/bank details, and createdByName (the employee who recorded it).
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate | query | string date | First day (inclusive), YYYY-MM-DD. Compared as a string against the payment day label. |
| endDate | query | string date | Last day (inclusive), YYYY-MM-DD. |
| timezone | query | string | IANA zone used to turn timestamped payments into a day for the date filter. Without it the UTC date prefix is used, so evening payments can land on the next day. |
| limit | query | integer default 100, range 1– | Target number of in-range rows per page (no upper bound enforced; default 100). Pages may exceed it. |
| cursor | query | string | Opaque cursor from a previous response's nextCursor. A malformed cursor is silently ignored and the listing restarts from the beginning (risk of duplicate rows). |
curl "https://api.bigshopai.com/v1/payments?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| payments[] * | object[] | Merged work-order + counter-sale payments, sorted newest-first by createdAt WITHIN this page only. |
| payments[].id * | string | UUID, or manual_<Idempotency-Key> for idempotent creates. |
| payments[].tenantId | string | |
| payments[].workOrderId | string | Work order OR counter-sale id the payment is applied to. |
| payments[].customerId | string | null | |
| payments[].orderNumber | string | |
| payments[].method * | string | Free-form string, not validated server-side (e.g. cash, card, check, ach, zelle, credit). |
| payments[].amountCents * | integer | Total collected = principal + card surcharge. Can be negative for adjustments. Integer cents. |
| payments[].surchargeAmountCents | integer | Card-surcharge part of amountCents; absent when none. Integer cents. |
| payments[].surchargeFee | number | Surcharge percent applied. |
| payments[].appliedToInvoiceCents | integer | Principal actually applied when an overpayment was split to store credit; absent otherwise. Integer cents. |
| payments[].creditedToCustomerCents | integer | Overpayment excess parked as store credit; absent otherwise. Integer cents. |
| payments[].status | string | completed on create; also refunded, voided, pending, failed elsewhere in the API. |
| payments[].manualPaymentDetails | object | Card brand/last4 for payments run on the shop's own terminal. |
| payments[].stripePaymentDetails | object | Present on online (Stripe) payments. |
| payments[].referenceNumber | string | null | |
| payments[].checkNumber | string | null | |
| payments[].bankName | string | null | |
| payments[].zelleEmail | string | null | Payer PII. |
| payments[].zellePhone | string | null | Payer PII. |
| payments[].notes | string | null | |
| payments[].createdBy | string | |
| payments[].createdByName | string | Employee who recorded the payment. |
| payments[].createdAt * | string | Payment timestamp (may be backdated for migrated payments). |
| payments[].paymentDate | string | Legacy/edited alias of createdAt. |
| payments[].date | string | Legacy alias. |
| payments[].createdAtOriginal | string | Present once an admin edited the date. |
| payments[].updatedAt | string | |
| payments[].refundedAmountCents | integer | Present on refunded payments. Integer cents. |
| payments[].refundedAt | string | |
| payments[].amount | number | LEGACY rows only: dollar amount. Prefer amountCents. DOLLARS as a decimal number (not cents). |
| total * | integer | Number of payments on THIS page (not the all-time or in-range total). |
| nextCursor | string | Opaque base64 cursor. Pass back as cursor to continue. Absent on the last page. |
| hasMore * | boolean | true when nextCursor is present. |
Errors
403— Forbidden. The caller's role lackspayments:read.500—{"error":"Internal Server Error"}.
Shop Financials
/shop-financials/months
List months that have shop financials
Answers: which months have overhead/expense and income bookkeeping, and what are their totals? Returns one record per month (newest first) with total expenses, total income, net, and entry counts. These are the shop-entered Shop Financials figures (rent, utilities, insurance, payroll as booked, etc.), not work-order revenue. All money is integer cents. Totals are stored on the month record and updated whenever an entry changes.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| year | query | integer | Only months in this calendar year (e.g. 2026). A non-numeric value is ignored (no filter). |
curl "https://api.bigshopai.com/v1/shop-financials/months" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| months[] * | object[] | Newest month first. Not paginated. |
| months[].monthKey * | string | |
| months[].year * | integer | |
| months[].month * | integer | |
| months[].status * | "open" | "closed" | "archived" | Declared type. Whether closed is ever stored on this row (vs the separate Period Close record) was not verified. |
| months[].totalExpensesCents * | integer | All expenses booked to the month, including any under the default 'Payroll' category. Integer cents. |
| months[].totalIncomeCents * | integer | Integer cents. |
| months[].netCents * | integer | Income minus expenses; may be negative. Integer cents. |
| months[].expenseCount * | integer | |
| months[].incomeCount * | integer | |
| months[].notes | string | |
| months[].createdAt | string date-time | |
| months[].updatedAt | string date-time |
Errors
401—{"error":"Unauthorized"}when the tenant cannot be resolved from the authorizer context.403—{"error":"You do not have permission to view shop financials."}- NOTE this route does not use the PERMISSION_DENIED body.500—{"error":"Something went wrong loading shop financials."}(orCould not complete the accounting request.from the accounting router).
/shop-financials/months/{month}
One month of shop financials with all expense and income lines
Answers: what overhead did the shop book in this month and what other income did it record? Returns the month record plus every expense line (vendor, category, amount, paid status, linked bank transaction), income line (with linked counter-sale status) and recorded payment/receipt transaction. These are shop-entered or bank-promoted entries - not derived from work orders.
All money is integer cents. The month must be YYYY-MM; a month that was never created returns 404.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| month * | path | string | Month key, YYYY-MM. Any other shape does not match this route and returns 404 'Shop Financials endpoint not found'. |
curl "https://api.bigshopai.com/v1/shop-financials/months/<month>" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| month * | object | |
| month.monthKey * | string | |
| month.year * | integer | |
| month.month * | integer | |
| month.status * | "open" | "closed" | "archived" | Declared type. Whether closed is ever stored on this row (vs the separate Period Close record) was not verified. |
| month.totalExpensesCents * | integer | All expenses booked to the month, including any under the default 'Payroll' category. Integer cents. |
| month.totalIncomeCents * | integer | Integer cents. |
| month.netCents * | integer | Income minus expenses; may be negative. Integer cents. |
| month.expenseCount * | integer | |
| month.incomeCount * | integer | |
| month.notes | string | |
| month.createdAt | string date-time | |
| month.updatedAt | string date-time | |
| expenses[] * | object[] | All expenses for the month (drained fully). |
| expenses[].id * | string | |
| expenses[].monthKey * | string | |
| expenses[].category * | string | Free text; default categories include Payroll, Rent/Mortgage, Insurance - Workers Comp, Utilities - Electric, ... |
| expenses[].vendor | string | |
| expenses[].description * | string | |
| expenses[].amountCents * | integer | Integer cents. |
| expenses[].frequency | "monthly" | "weekly" | "one-time" | "quarterly" | "annual" | |
| expenses[].startDate | string | |
| expenses[].endDate | string | |
| expenses[].paymentStatus | "paid" | "unpaid" | "scheduled" | Promoted to paid when the expense is linked to a cleared bank transaction. |
| expenses[].paymentMethod | string | |
| expenses[].notes | string | |
| expenses[].isFixed | boolean | |
| expenses[].isTaxDeductible | boolean | |
| expenses[].costCenter | string | |
| expenses[].receiptExtracted | boolean | |
| expenses[].sourceTransactionId | string | Set when promoted from a bank transaction. |
| expenses[].sourceTransaction | object | Added at read time for bank-linked expenses. |
| expenses[].sourceTransaction.transactionId | string | |
| expenses[].sourceTransaction.date | string | Day the money left the account. |
| expenses[].sourceTransaction.name | string | Merchant/bank description. |
| expenses[].sourceTransaction.amountCents | integer | Signed cents; an expense's source is negative. Integer cents. |
| expenses[].sourceTransaction.accountName | string | null | |
| expenses[].createdAt | string date-time | |
| expenses[].updatedAt | string date-time | |
| income[] * | object[] | All income entries for the month. |
| income[].id * | string | |
| income[].monthKey * | string | |
| income[].customerId | string | |
| income[].customerName | string | |
| income[].description | string | |
| income[].category * | string | |
| income[].amountCents * | integer | Integer cents. |
| income[].frequency | "monthly" | "weekly" | "one-time" | |
| income[].paymentStatus | "unpaid" | "partially_paid" | "paid" | Promoted from the linked counter sale at read time; never demoted. |
| income[].invoiceId | string | |
| income[].invoiceNumber | string | Counter-sale number, e.g. SO-000123. |
| income[].notes | string | |
| income[].saleStatus | string | null | Linked counter sale status (read-time enrichment). |
| income[].salePaidCents | integer | Amount paid on the linked counter sale (read-time enrichment). Integer cents. |
| income[].createdAt | string date-time | |
| income[].updatedAt | string date-time | |
| transactions[] * | object[] | Recorded payments/receipts against expenses and income. |
| transactions[].id * | string | |
| transactions[].monthKey * | string | |
| transactions[].type * | "expense_payment" | "income_receipt" | |
| transactions[].referenceId * | string | Expense or income id. |
| transactions[].amountCents * | integer | Integer cents. |
| transactions[].date * | string | |
| transactions[].method | string | |
| transactions[].notes | string | |
| transactions[].createdAt | string date-time |
Errors
401—{"error":"Unauthorized"}.403—{"error":"You do not have permission to view shop financials."}.404—{"error":"Month not found"}(no such month for this shop) or{"error":"Shop Financials endpoint not found","details":{path,method}}(malformed month).500—{"error":"Something went wrong loading shop financials."}.
Technicians & Labor
/work-orders/technician-labor-summary
Per-technician completed labor hours, revenue and cost
Answers: how many labor hours did each technician complete in this period, what did that labor bill, and what did it cost the shop? Per technician: hours, labor revenue, jobs, hours per day and a line-by-line breakdown. LABOR ONLY (parts are not attributed to technicians).
basis decides which date puts a labor line in the period: labor-completed (the day the line was completed) or invoiced (the day its work order was invoiced). The shop's own setting (Settings > Labor, billableHoursBasis) is the default; you may request either value via basis. Days are shop-local.
EMPLOYEE PAY DATA: laborCostCents, commissionCents and breakdown[].costCents reveal what the shop pays each technician (hourly cost rate x hours; flat-weekly staff prorated) and their commission. The server removes these fields for callers without reports:profit or attendance:financials; API keys authenticate as admin and always receive them.
All money is integer cents; hours are decimal. The read is capped at 5000 work orders; truncated: true means totals are partial.
Parameters
| Name | In | Type | Description |
|---|---|---|---|
| startDate * | query | string date | First shop-local day, YYYY-MM-DD. Required (a longer ISO timestamp is accepted: only the first 10 characters are used). |
| endDate * | query | string date | Last shop-local day (inclusive). Required. |
| basis | query | "labor-completed" | "invoiced" | Which date decides the period. Omitted or any other value falls back to the shop's billableHoursBasis setting (default labor-completed). |
curl "https://api.bigshopai.com/v1/work-orders/technician-labor-summary?startDate=2026-10-01&endDate=2026-10-09" \
-H "Authorization: ApiKey bsk_live_..."
Response fields (first 3 levels)
| Field | Type | Description |
|---|---|---|
| technicians[] * | object[] | Sorted by laborRevenueCents descending. |
| technicians[].technicianId * | string | |
| technicians[].technicianName * | string | |
| technicians[].laborHours * | number | Completed labor hours attributed to this technician in the window. |
| technicians[].laborRevenueCents * | integer | Labor billed for those hours (labor only; parts excluded by design). Integer cents. |
| technicians[].laborCostCents | integer | PAY DATA. What the shop pays for those hours (hourly cost rate x hours; weekly-flat staff are prorated). OMITTED for callers without reports:profit or attendance:financials. Integer cents. |
| technicians[].commissionCents | integer | PAY DATA. Commission earned on those hours at the technician's labor-commission %. OMITTED for callers without reports:profit or attendance:financials. Integer cents. |
| technicians[].jobsCompleted * | integer | Distinct work orders contributing. |
| technicians[].dailyHours * | object | Hours keyed by the shop-local day (YYYY-MM-DD) each line counted on under the basis. |
| technicians[].breakdown[] * | object[] | One entry per completed labor line (can be large). |
| technicians[].breakdown[].orderId * | string | |
| technicians[].breakdown[].orderNumber * | string | Invoice number, else order number, else id. |
| technicians[].breakdown[].isInvoiced * | boolean | |
| technicians[].breakdown[].status * | string | |
| technicians[].breakdown[].date * | string | Invoiced/created timestamp of the work order. |
| technicians[].breakdown[].customerName * | string | |
| technicians[].breakdown[].vehicleLabel * | string | |
| technicians[].breakdown[].description * | string | Labor line description. |
| technicians[].breakdown[].hours * | number | This technician's share of the line's hours. |
| technicians[].breakdown[].revenueCents * | integer | This technician's share of the line's labor revenue (net of discount). Integer cents. |
| technicians[].breakdown[].costCents | integer | PAY DATA. Hours x the technician's hourly cost rate in force on the line's date (or the stored line cost). Dividing by hours reveals the rate. OMITTED for callers without reports:profit / attendance:financials; API keys (role admin) always receive it. Integer cents. |
| startDate * | string date | |
| endDate * | string date | |
| basis * | "labor-completed" | "invoiced" | Which date decides the period: the day each labor line was completed, or the day its work order was invoiced. |
| workOrdersScanned * | integer | |
| truncated * | boolean | true when the 5000-work-order read cap was hit; totals are then partial. |
Errors
400—{"error":"startDate and endDate are required (YYYY-MM-DD)"}.403— Forbidden. The caller's role lacksworkorders:read / workorders:read-all.500— Unexpected server error:{"error":"Internal Server Error","message":"An unexpected error occurred"}.