Skip to content
Skip to main content
Novel Systems home
API reference

Novel Systems API

Multi-tenant CPQ and field-service dispatch for specialty-trade contractors.

  • 20

    Operations across 18 paths, every one of them below with its request and response shape.

  • 6

    Answer without a token — health, routes, connector status, login and the two callbacks a provider posts to.

  • 2026-07-01

    Current dated version. Breaking changes ship as a new date, never as an edit to this one.

Generated from /openapi.json, which is itself emitted from the request validators the API parses with. This page cannot describe a field the service does not accept.

  • Base URL

    https://novel-systems-backend.vercel.app

    Production. Canadian data residency. This is the host that answers today; api.novelsystems.ca is not yet provisioned and is deliberately not listed.

  • Authentication

    Authorization: Bearer <token> · bearer (JWT)

    The `access_token` from POST /api/v1/auth/login. Carries the organization and role; both are re-checked against the database on every request.

Generated from the Zod schemas the server validates against — see `backend/src/schemas/` and `backend/scripts/generate-openapi.ts`. Do not hand-edit this file; `npm run openapi:check` will fail on it in CI.

Every route below except `/api/health` requires a bearer token from `POST /api/v1/auth/login`. Tokens are scoped to one organization and one role; the row-level security policies on the database enforce the same boundary independently, so a token cannot read another tenant's data even if a handler forgets to filter.

Money is always an integer count of cents. Exact decimals — margins, rate multipliers — are strings, never JSON numbers, so that a client cannot silently parse them into a float.

Contents

20 operations, grouped the way the document groups them

Tag order is the order the OpenAPI document declares, not alphabetical — it is roughly the order an integration is built in, and re-sorting it would throw that away.

Infrastructure

Unauthenticated liveness.

get/api/healthNo token

Liveness and database reachability.

getHealth

Unauthenticated and outside /v1 on purpose: it is infrastructure, not API surface, and the load balancer that calls it has no credentials. Returns 503 rather than 200-with-a-status-field, because a green check on a broken service is worse than no check at all.

Responses

200The process can reach its database.

When status = "ok"

200 response for GET /api/health — status = "ok"
FieldTypeRulesNotes
status*"ok"——
database*"reachable"——
latency_ms*integer≥ 0—
build*objectno extra keysWhich commit, branch and environment this deployment was built from.
build.commit*stringnullable—
build.ref*stringnullable—
build.environment*stringnullable—
timestamp*string——

When status = "degraded"

200 response for GET /api/health — status = "degraded"
FieldTypeRulesNotes
status*"degraded"——
database*"unreachable"——
build*objectno extra keysWhich commit, branch and environment this deployment was built from.
build.commit*stringnullable—
build.ref*stringnullable—
build.environment*stringnullable—
timestamp*string——
503The process cannot reach its database.

When status = "ok"

503 response for GET /api/health — status = "ok"
FieldTypeRulesNotes
status*"ok"——
database*"reachable"——
latency_ms*integer≥ 0—
build*objectno extra keysWhich commit, branch and environment this deployment was built from.
build.commit*stringnullable—
build.ref*stringnullable—
build.environment*stringnullable—
timestamp*string——

When status = "degraded"

503 response for GET /api/health — status = "degraded"
FieldTypeRulesNotes
status*"degraded"——
database*"unreachable"——
build*objectno extra keysWhich commit, branch and environment this deployment was built from.
build.commit*stringnullable—
build.ref*stringnullable—
build.environment*stringnullable—
timestamp*string——
get/api/routesNo token

The route table of the process answering this request.

getRoutes

Exists so that a contract check can fail. The obvious way to ask a live deployment whether it serves a path is to call it unauthenticated and read the status, and that does not work here: the guards are mounted on the routers rather than on each route, so authentication runs before Express matches a path within the router and a real path and an invented one both answer 401 with the same envelope. Under `/api/v1` that test cannot fail, which is a worse state than having no test.

So the process says what it serves, walked from the router stack it actually dispatches against — not parsed from source, because the deployed artefact is built from a different repository and reading source in CI cannot describe it. `scripts/check-deployed-api.mjs` diffs this against the document you are reading now.

Unauthenticated, and it discloses nothing this document does not already publish: methods and paths, no handlers, no schemas, no configuration. The authorisation on each route is the control, and it is unchanged.

Responses

200Every method and path this deployment will dispatch, sorted by path then method so two deployments compare as text.
200 response for GET /api/routes
FieldTypeRulesNotes
count*integer≥ 0—
routes*array of object——
routes[].method*"GET" | "POST" | "PATCH" | "PUT" | "DELETE"——
routes[].path*string——
get/api/connectorsNo token

Which of the three integrations this deployment can actually begin.

getConnectors

Unauthenticated, for the audience that cannot authenticate: someone evaluating whether a connector they read about on the marketing site is a switch they can flip. The field mappings, the system-of-record rules and the deployment path are all published as prose. This is the endpoint behind them.

Three states, derived from the adapters rather than typed anywhere:

- `operator_credentials_missing` — this deployment holds no application credentials for the provider. There is no consent screen to send anyone to, and no tenant can begin. The connector is built; it is not connectable here. - `awaiting_tenant_consent` — the platform side is complete. Each organisation authorises its own account and nothing syncs until an administrator does. - `platform_account_live` — configured, and the account is ours rather than the tenant's. Stripe is the only provider shaped this way.

It reports no tenant rows and does not name the environment variables behind a gap. Those stay behind the ADMIN guard on `GET /api/v1/integrations`, which answers the operator's question — *what do I set* — rather than the buyer's. Whether a Connect button would work is a fact anyone can establish by pressing it; the name of the variable holding a client secret is not.

Responses

200Every provider this build ships an adapter for, in registry order, with the state each is actually in right now.
200 response for GET /api/connectors
FieldTypeRulesNotes
count*integer≥ 0—
connectors*array of object——
connectors[].slug*"stripe" | "quickbooks" | "salesforce"——
connectors[].display_name*string——
connectors[].platform_ready*boolean——
connectors[].readiness*"operator_credentials_missing" | "awaiting_tenant_consent" | "platform_account_live"——
connectors[].summary*string——

Authentication

Tokens and identity.

post/api/v1/auth/loginNo token

Exchange workspace, email and password for a bearer token.

login

The workspace subdomain is part of the credential, not something discovered from the email: addresses are unique per organization, not globally. Wrong password, unknown email, unknown workspace and disabled account all return the same 401 with the same timing.

Request body

Workspace, email and password.

Request body for POST /api/v1/auth/login
FieldTypeRulesNotes
subdomain*string1–63 chars—
email*stringmax 320 chars, email—
password*string1–200 chars—

Responses

200A token and the caller's identity.
200 response for POST /api/v1/auth/login
FieldTypeRulesNotes
access_token*string——
token_type*"Bearer"——
expires_in*integer> 0—
user*objectno extra keys—
user.id*stringuuid—
user.email*stringemail—
user.role*"ADMIN" | "SALES" | "TECHNICIAN"——
user.organization_id*stringuuid—
401Credentials rejected. The body does not say which part was wrong.

The Error schema, in full at the foot of this page.

422The body failed validation.

The Error schema, in full at the foot of this page.

429Ten attempts per fifteen minutes against one account. Keyed on the workspace and email being attempted, not on the caller's IP.

The Error schema, in full at the foot of this page.

get/api/v1/auth/meBearer token

The caller's own profile and organization.

getCurrentUser

Exists so a client can recover its identity after a reload without decoding the token itself. The role comes from the database, not from the token, so a role changed after issue takes effect on the next call.

Responses

200The caller's profile.
200 response for GET /api/v1/auth/me
FieldTypeRulesNotes
id*stringuuid—
email*stringemail—
full_name*string——
role*"ADMIN" | "SALES" | "TECHNICIAN"——
last_login_at*stringnullableISO-8601 timestamp, UTC.
organization*objectno extra keys—
organization.id*stringuuid—
organization.name*string——
organization.subdomain*string——
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

Quotes

Server-side pricing. ADMIN and SALES only.

get/api/v1/quotesBearer token

The tenant's quotes, newest first.

listQuotes

Cursor paginated, not offset: this table grows while you read it, and offsets both skip and repeat rows when a colleague saves a quote mid-scroll. Pass the `next_cursor` from the previous page as `cursor`.

Parameters

Parameters for GET /api/v1/quotes
FieldTypeRulesNotes
status"DRAFT" | "SENT" | "APPROVED" | "REJECTED" | "EXPIRED"query—
limitintegerquery, ≥ 1, ≤ 100, default 25—
cursorstringquery, uuid—

Responses

200One page of quotes.
200 response for GET /api/v1/quotes
FieldTypeRulesNotes
data*array of object——
data[].id*stringuuid—
data[].quote_number*integer——
data[].status*"DRAFT" | "SENT" | "APPROVED" | "REJECTED" | "EXPIRED"——
data[].total_amount_cents*integer——
data[].margin_percent*string—An exact decimal, as a string. Do not parse it into a float.
data[].customer_name*stringnullable—
data[].created_at*string—ISO-8601 timestamp, UTC.
data[].created_by*objectnullable, no extra keys—
data[].created_by.id*stringuuid—
data[].created_by.name*string——
data[].created_by.email*stringemail—
next_cursor*stringuuid, nullable—
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token. This resource carries cost and margin.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

post/api/v1/quotes/calculateBearer token

Price a configuration server-side.

calculateQuote

The client sends dimensions and SKU codes and nothing else. It does not send costs, and if it did they would be ignored: every figure in the response is resolved from the tenant's own rate card inside the transaction.

`targetMargin` is a fraction — 0.50, never 50. Omitting it prices at the floor. A value above the floor is accepted without ceremony; a value below it requires `marginOverride.reason` and an ADMIN token, and the reason is stored on the quote. See D-001 in DECISIONS.md.

`persist: false` prices and discards. `persist: true` writes the quote and returns its id and per-tenant sequential number.

Request body

Labour tier, line items, margin intent.

Request body for POST /api/v1/quotes/calculate
FieldTypeRulesNotes
laborTierCode*string1–32 chars—
lineItems*array of object1–200 items—
lineItems[].referencestringmax 120 chars—
lineItems[].widthInches*number> 0—
lineItems[].dropInches*number> 0—
lineItems[].quantity*integer≥ 1, ≤ 500—
lineItems[].fabricSkuCode*string1–64 chars—
lineItems[].hardwareSkuCode*string1–64 chars—
lineItems[].motorSkuCodestring1–64 chars—
lineItems[].trimSkuCodestring1–64 chars—
targetMarginnumber≥ 0, < 1—
marginOverrideobjectno extra keys—
marginOverride.reason*string10–500 chars—
customerobjectno extra keys—
customer.name*string1–200 chars—
customer.emailstringmax 320 chars, email—
persistbooleandefault false—

Responses

200The priced quote. Cut sizes are per unit; totals are for the whole order.
200 response for POST /api/v1/quotes/calculate
FieldTypeRulesNotes
currency*"CAD"——
labor_tier*objectno extra keys—
labor_tier.code*string——
labor_tier.name*string——
labor_tier.hourly_rate_cents*integer——
labor_tier.multiplier*string—An exact decimal, as a string. Do not parse it into a float.
margin_floor*string—An exact decimal, as a string. Do not parse it into a float.
applied_margin*string—An exact decimal, as a string. Do not parse it into a float.
floor_overridden*boolean——
target_margin*string—Deprecated. An alias of applied_margin.
line_items*array of object——
line_items[].reference*stringnullable—
line_items[].width_inches*number——
line_items[].drop_inches*number——
line_items[].quantity*integer——
line_items[].opening_square_feet*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].fabric_square_feet_with_waste*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].labour_hours*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].cut_list*objectno extra keysPer-unit cut sizes in inches. Identical to what the browser sandbox shows.
line_items[].cut_list.tube_length_inches*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].cut_list.hembar_length_inches*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].cut_list.fabric_width_inches*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].cut_list.fabric_drop_inches*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].cut_list.fabric_square_feet*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].components*array of object——
line_items[].components[].component*"fabric" | "hardware" | "motor" | "trim" | "labour"——
line_items[].components[].sku_code*stringnullable—
line_items[].components[].description*string——
line_items[].components[].quantity*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].components[].unit*string——
line_items[].components[].unit_cost_cents*string—An exact decimal, as a string. Do not parse it into a float.
line_items[].components[].extended_cost_cents*integer——
line_items[].cost_cents*integer——
line_items[].price_cents*integer——
totals*objectno extra keys—
totals.cost_cents*integer——
totals.amount_cents*integer——
totals.profit_cents*integer——
totals.margin_percent*string—An exact decimal, as a string. Do not parse it into a float.
assumptions*objectno extra keys—
assumptions.fabric_waste_factor*string—An exact decimal, as a string. Do not parse it into a float.
assumptions.labour_base_hours_per_unit*string—An exact decimal, as a string. Do not parse it into a float.
assumptions.labour_hours_per_square_foot*string—An exact decimal, as a string. Do not parse it into a float.
assumptions.labour_motorisation_hours*string—An exact decimal, as a string. Do not parse it into a float.
quote_id*stringuuid, nullable—
quote_number*integernullable—
status*"DRAFT" | "SENT" | "APPROVED" | "REJECTED" | "EXPIRED"nullable—
persisted*boolean——
400A SKU code, labour tier, or margin the engine refused — including a margin below the floor without an override.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token, or an override attempted by a non-ADMIN.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

post/api/v1/quotes/{id}/approveBearer token

Approve a quote and fire `quote.approved`.

approveQuote

Its own endpoint rather than a PATCH on status, because approval is the moment a quote stops being a working document and becomes a commitment. It has side effects — webhooks to the tenant's accounting system — that a general-purpose field update should not be able to trigger by accident.

Parameters

Parameters for POST /api/v1/quotes/{id}/approve
FieldTypeRulesNotes
id*stringpath, uuid—

Responses

200The approved quote.
200 response for POST /api/v1/quotes/{id}/approve
FieldTypeRulesNotes
id*stringuuid—
quote_number*integer——
status*"DRAFT" | "SENT" | "APPROVED" | "REJECTED" | "EXPIRED"——
approved_at*stringnullableISO-8601 timestamp, UTC.
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token.

The Error schema, in full at the foot of this page.

404No quote with that id in this tenant.

The Error schema, in full at the foot of this page.

409The quote is not in a state that can be approved.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

post/api/v1/quotes/{id}/checkout-sessionBearer token

Mint a hosted Stripe payment page for an approved quote.

createQuoteCheckoutSession

The *pay now* path. Approval already fires an invoice with net-14 terms through the integration fan-out, which is what a commercial buyer expects; this endpoint is the other option — a hosted card page, returned as a URL the product can put behind a button. Neither is the default, because which one a job wants is a commercial question.

Amounts are the quote's own per-line integers, sent with `quantity: 1` so Stripe cannot recompute a line and land a cent away from the document that was approved. HST is an explicit thirteen-percent line, not Stripe Tax, which computes zero until a registration is configured and does so silently.

`return_url` is optional and must be an origin already in `CORS_ALLOWED_ORIGINS`. A caller-supplied redirect echoed into a page hosted on `checkout.stripe.com` is an open redirect on an origin both a phishing filter and a human trust, so the allowlist is checked rather than the string merely being parsed. Omitting it returns the payer to the first configured origin.

Calling twice returns the still-open session with `reused: true` rather than minting a second live payment link. Once the quote is paid the endpoint returns 409 — a second payable page for a settled job is how a customer pays twice.

Parameters

Parameters for POST /api/v1/quotes/{id}/checkout-session
FieldTypeRulesNotes
id*stringpath, uuid—

Request body

Optional return target. Send `{}` to use the deployment's default origin.

Request body for POST /api/v1/quotes/{id}/checkout-session
FieldTypeRulesNotes
return_urlstringmax 2000 chars, uri—

Responses

200The session created by an earlier call, still open. `reused` is true and no second payment link was minted.
200 response for POST /api/v1/quotes/{id}/checkout-session
FieldTypeRulesNotes
session_id*string——
quote_number*integer——
url*stringnullable—
status*stringnullable—
payment_status*stringnullable—
amount_total_cents*integernullable—
currency*stringnullable—
expires_at*stringnullableISO-8601 timestamp, UTC.
reused*boolean——
201A newly created payable page. `url` is where to send the payer.
201 response for POST /api/v1/quotes/{id}/checkout-session
FieldTypeRulesNotes
session_id*string——
quote_number*integer——
url*stringnullable—
status*stringnullable—
payment_status*stringnullable—
amount_total_cents*integernullable—
currency*stringnullable—
expires_at*stringnullableISO-8601 timestamp, UTC.
reused*boolean——
400The quote is not approved, or `return_url` is not an origin this deployment will send a payer to.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token.

The Error schema, in full at the foot of this page.

404No quote with that id in this tenant.

The Error schema, in full at the foot of this page.

409The quote is already paid.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

503`STRIPE_SECRET_KEY` is not set on this deployment.

The Error schema, in full at the foot of this page.

get/api/v1/quotes/{id}/invoiceBearer token

The Stripe invoice written when this quote was approved.

getQuoteInvoice

Approval fans out to the configured connectors, and Stripe's adapter responds by resolving a customer, posting one invoice item per quote line plus an explicit HST line, creating the invoice and finalising it. This endpoint returns that document. It is a read — nothing at Stripe is created or changed by calling it.

It is the companion to `POST /quotes/{id}/checkout-session`, not a duplicate of it. That endpoint mints a hosted card page for paying now; this one returns the net-14 invoice that already exists, with `hosted_invoice_url` and `invoice_pdf_url` pointing at Stripe's own copies.

`mode` is derived from the `livemode` flag Stripe stamps on the object, not from which key the deployment holds — so a caller asking whether it is looking at test data gets an answer that travelled with the artifact.

`tax_cents` is nullable and the nullability is deliberate. This platform does not use Stripe Tax, which computes zero until a registration is configured on the account and does so silently; HST is posted as an ordinary line and identified on the way back by the description we wrote. Null means no line carried that label. Zero would mean the invoice has no tax on it, which on a Canadian total is a different claim.

Parameters

Parameters for GET /api/v1/quotes/{id}/invoice
FieldTypeRulesNotes
id*stringpath, uuid—

Responses

200The finalised invoice, with its line items and Stripe's hosted copies.
200 response for GET /api/v1/quotes/{id}/invoice
FieldTypeRulesNotes
quote_number*integer——
invoice_id*string——
number*stringnullable—
status*stringnullable—
mode*"test" | "live" | "unknown"——
currency*string——
subtotal_cents*integernullable—
tax_cents*integernullable—
total_cents*integernullable—
amount_due_cents*integernullable—
created_at*stringnullableISO-8601 timestamp, UTC.
hosted_invoice_url*stringnullable—
invoice_pdf_url*stringnullable—
lines*array of object——
lines[].description*string——
lines[].amount_cents*integer——
lines[].is_tax*boolean——
created*boolean——
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token.

The Error schema, in full at the foot of this page.

404No quote with that id in this tenant, or no invoice was recorded for it — a quote produces one when it is approved. `POST` to this same path writes the missing one.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

503`STRIPE_SECRET_KEY` is not set on this deployment.

The Error schema, in full at the foot of this page.

post/api/v1/quotes/{id}/invoiceBearer token

Make sure an approved quote has an invoice at Stripe.

writeQuoteInvoice

The repair path for the `GET` above, and the only way an already-approved quote can acquire an invoice.

Approval fires the connector fan-out exactly once and `POST /approve` answers 409 on a second attempt — deliberately, because two approvals would mean two invoices for one job. The consequence is that a quote whose Stripe leg failed, or one approved before the connector existed, had no second chance. This is it.

Idempotent, and by three independent mechanisms rather than one. A recorded successful sync short-circuits before any call to Stripe and the existing invoice is returned unchanged. Every mutating call the adapter makes carries an `Idempotency-Key` derived from the quote id, so a retry after a timeout re-reads Stripe's stored response rather than creating a second document. And the sync table's unique key on `(organization, provider, entity type, quote)` makes the record itself single. So the accurate description is not "this creates an invoice" but "this ensures exactly one exists" — `created` tells you which case you were in, and `201` versus `200` says the same thing.

It repairs one connector for one quote. It does not re-fire the tenant's `quote.approved` webhook or the QuickBooks and Salesforce legs: a missing Stripe invoice is not a reason to tell everyone else the quote was approved a second time.

Amounts come from the calculation stored at approval, never from a recalculation. On a path that may run an arbitrary time after the quote was agreed, today's SKU costs would bill a different document from the one the customer accepted.

Parameters

Parameters for POST /api/v1/quotes/{id}/invoice
FieldTypeRulesNotes
id*stringpath, uuid—

Responses

200An invoice already existed for this quote; it is returned untouched, with `created: false`.
200 response for POST /api/v1/quotes/{id}/invoice
FieldTypeRulesNotes
quote_number*integer——
invoice_id*string——
number*stringnullable—
status*stringnullable—
mode*"test" | "live" | "unknown"——
currency*string——
subtotal_cents*integernullable—
tax_cents*integernullable—
total_cents*integernullable—
amount_due_cents*integernullable—
created_at*stringnullableISO-8601 timestamp, UTC.
hosted_invoice_url*stringnullable—
invoice_pdf_url*stringnullable—
lines*array of object——
lines[].description*string——
lines[].amount_cents*integer——
lines[].is_tax*boolean——
created*boolean——
201The invoice was written and finalised by this call. `created: true`.
201 response for POST /api/v1/quotes/{id}/invoice
FieldTypeRulesNotes
quote_number*integer——
invoice_id*string——
number*stringnullable—
status*stringnullable—
mode*"test" | "live" | "unknown"——
currency*string——
subtotal_cents*integernullable—
tax_cents*integernullable—
total_cents*integernullable—
amount_due_cents*integernullable—
created_at*stringnullableISO-8601 timestamp, UTC.
hosted_invoice_url*stringnullable—
invoice_pdf_url*stringnullable—
lines*array of object——
lines[].description*string——
lines[].amount_cents*integer——
lines[].is_tax*boolean——
created*boolean——
400The quote is not approved. An invoice belongs to an approved quote.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A TECHNICIAN token.

The Error schema, in full at the foot of this page.

404No quote with that id in this tenant.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

502Stripe refused. The attempt is recorded against the quote with its attempt count incremented, and the call can be repeated.

The Error schema, in full at the foot of this page.

503`STRIPE_SECRET_KEY` is not set on this deployment.

The Error schema, in full at the foot of this page.

Work orders

Dispatch and field status. TECHNICIAN-readable.

get/api/v1/work-ordersBearer token

Scheduled work, soonest first.

listWorkOrders

A TECHNICIAN sees only their own jobs — the mobile app renders this list as the day's route. Note what the response does not carry: no cost, no margin, no quote total.

Parameters

Parameters for GET /api/v1/work-orders
FieldTypeRulesNotes
status"PENDING" | "DISPATCHED" | "COMPLETED" | "CANCELLED"query—
scheduledOnstringqueryCalendar date, YYYY-MM-DD.
limitintegerquery, ≥ 1, ≤ 100, default 50—

Responses

200The matching work orders.
200 response for GET /api/v1/work-orders
FieldTypeRulesNotes
data*array of object——
data[].id*stringuuid—
data[].status*"PENDING" | "DISPATCHED" | "COMPLETED" | "CANCELLED"——
data[].scheduled_date*stringnullableISO-8601 timestamp, UTC.
data[].site_address*stringnullable—
data[].notes*stringnullable—
data[].assigned_technician_id*stringuuid, nullable—
data[].quote_id*stringuuid, nullable—
data[].dispatched_at*stringnullableISO-8601 timestamp, UTC.
data[].completed_at*stringnullableISO-8601 timestamp, UTC.
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

post/api/v1/work-ordersBearer token

Dispatch a job. ADMIN only.

createWorkOrder

Creating work is a dispatch decision; quoting it is not. Both references are verified inside the tenant transaction before the insert, so a cross-tenant id returns a 404 naming which reference was wrong rather than an opaque conflict.

Request body

Schedule, site, and assignment.

Request body for POST /api/v1/work-orders
FieldTypeRulesNotes
quoteIdstringuuid—
assignedTechnicianIdstringuuid—
scheduledDatestring—Calendar date, YYYY-MM-DD.
siteAddressstringmax 400 chars—
notesstringmax 2000 chars—

Responses

201The created work order.
201 response for POST /api/v1/work-orders
FieldTypeRulesNotes
id*stringuuid—
status*"PENDING" | "DISPATCHED" | "COMPLETED" | "CANCELLED"——
scheduled_date*stringnullableISO-8601 timestamp, UTC.
assigned_technician_id*stringuuid, nullable—
400The assignee is not a TECHNICIAN, or their account is deactivated.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403Not an ADMIN token.

The Error schema, in full at the foot of this page.

404The quote or technician id does not exist in this tenant.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

patch/api/v1/work-orders/{id}/statusBearer token

Move a work order through the dispatch state machine.

updateWorkOrderStatus

PENDING → DISPATCHED or CANCELLED. DISPATCHED → COMPLETED, PENDING or CANCELLED. COMPLETED and CANCELLED are terminal: reopening a completed job would destroy the timestamp billing runs off, and a cancelled job that comes back is a new work order.

Idempotent. Sending the status a record already has returns 200 with `unchanged: true` rather than a conflict, because a technician on intermittent signal will send the same completion twice.

Parameters

Parameters for PATCH /api/v1/work-orders/{id}/status
FieldTypeRulesNotes
id*stringpath, uuid—

Request body

The target status and optional notes.

Request body for PATCH /api/v1/work-orders/{id}/status
FieldTypeRulesNotes
status*"PENDING" | "DISPATCHED" | "COMPLETED" | "CANCELLED"——
notesstringmax 2000 chars—

Responses

200The work order's new state.
200 response for PATCH /api/v1/work-orders/{id}/status
FieldTypeRulesNotes
id*stringuuid—
status*"PENDING" | "DISPATCHED" | "COMPLETED" | "CANCELLED"——
unchanged*boolean——
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403A technician who is not the one assigned to this job.

The Error schema, in full at the foot of this page.

404No work order with that id in this tenant.

The Error schema, in full at the foot of this page.

409The transition is not permitted from the current status.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

Integrations

Stripe, QuickBooks and Salesforce. ADMIN only, except the two endpoints the providers themselves call.

get/api/v1/integrationsBearer token

Every provider this deployment knows about, and the caller's own connection.

listIntegrations

Two different questions, answered separately on purpose. `configured` is about the platform: whether this deployment holds the client credentials the provider needs at all. `connection` is about the tenant: whether this organization has consented. A UI that conflates them tells an administrator to reconnect when the thing that is actually missing is an environment variable only we can set, and `configuration.summary` exists so the wrong instruction is never the one shown.

No token, ciphertext or secret appears in this response. The only thing said about credentials is when they expire.

Responses

200One entry per provider, connected or not.
200 response for GET /api/v1/integrations
FieldTypeRulesNotes
data*array of object——
data[].provider*"STRIPE" | "QUICKBOOKS" | "SALESFORCE"——
data[].slug*"stripe" | "quickbooks" | "salesforce"——
data[].display_name*string——
data[].configured*boolean——
data[].configuration*objectno extra keys—
data[].configuration.missing_environment*array of string——
data[].configuration.awaiting_tenant_consent*boolean——
data[].configuration.summary*string——
data[].connection*objectnullable, no extra keys—
data[].connection.status*"DISCONNECTED" | "CONNECTED" | "EXPIRED" | "ERROR"——
data[].connection.external_account_id*stringnullable—
data[].connection.instance_url*stringnullable—
data[].connection.scopes*array of string——
data[].connection.connected_at*stringnullableISO-8601 timestamp, UTC.
data[].connection.last_sync_at*stringnullableISO-8601 timestamp, UTC.
data[].connection.last_error*stringnullable—
data[].connection.access_token_expires_at*stringnullableISO-8601 timestamp, UTC.
data[].connection.refresh_token_expires_at*stringnullableISO-8601 timestamp, UTC.
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403Not an ADMIN token.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

get/api/v1/integrations/syncsBearer token

What each provider did with each approved quote.

listIntegrationSyncs

The answer to "did the invoice go out". A SKIPPED row is a real answer — it means the provider was not connected when the quote was approved, so nothing was attempted — and it is recorded rather than left as silence, because silence is indistinguishable from a bug.

Parameters

Parameters for GET /api/v1/integrations/syncs
FieldTypeRulesNotes
provider"stripe" | "quickbooks" | "salesforce"query—
limitintegerquery, ≥ 1, ≤ 100, default 25—

Responses

200The most recent sync attempts.
200 response for GET /api/v1/integrations/syncs
FieldTypeRulesNotes
data*array of object——
data[].id*stringuuid—
data[].provider*"STRIPE" | "QUICKBOOKS" | "SALESFORCE"——
data[].entity_type*string——
data[].local_id*string——
data[].external_id*stringnullable—
data[].status*"PENDING" | "SUCCEEDED" | "FAILED" | "SKIPPED"——
data[].attempts*integer≥ 0—
data[].amount_cents*integernullable—
data[].last_error*stringnullable—
data[].synced_at*stringnullableISO-8601 timestamp, UTC.
401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403Not an ADMIN token.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

post/api/v1/integrations/{provider}/connectBearer token

Begin an OAuth consent flow.

connectIntegration

Returns the provider's consent URL rather than redirecting to it. A 302 from an XHR is followed by the browser transparently and lands the provider's HTML consent page inside a fetch the client cannot render, so the client has to move the top-level window itself.

`stripe` is rejected with a 400. Stripe here is the platform's own account, not a per-tenant grant, so there is nothing for a tenant to consent to — and a connect flow that silently succeeds while doing nothing is worse than a refusal that says why.

Parameters

Parameters for POST /api/v1/integrations/{provider}/connect
FieldTypeRulesNotes
provider*"stripe" | "quickbooks" | "salesforce"path—

Responses

200Where to send the browser.
200 response for POST /api/v1/integrations/{provider}/connect
FieldTypeRulesNotes
provider*"stripe" | "quickbooks" | "salesforce"——
authorize_url*stringuri—
expires_in_seconds*integer> 0—
400`stripe`, which has no per-tenant flow; or the provider's client credentials are not configured on this deployment; or INTEGRATIONS_ENCRYPTION_KEY is unset, so no credential could be stored.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403Not an ADMIN token.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

delete/api/v1/integrations/{provider}Bearer token

Forget this organization's credentials for a provider.

disconnectIntegration

Idempotent, and destructive on purpose: the stored ciphertext is cleared rather than superseded. A history of retired refresh tokens is a history of live ones. Sync rows are kept — they are the tenant's audit trail of invoices that already exist in someone else's system, and deleting them would not unsend anything.

Parameters

Parameters for DELETE /api/v1/integrations/{provider}
FieldTypeRulesNotes
provider*"stripe" | "quickbooks" | "salesforce"path—

Responses

200The provider is now disconnected.
200 response for DELETE /api/v1/integrations/{provider}
FieldTypeRulesNotes
provider*"stripe" | "quickbooks" | "salesforce"——
status*"DISCONNECTED" | "CONNECTED" | "EXPIRED" | "ERROR"——
400`stripe`, which is configured platform-wide.

The Error schema, in full at the foot of this page.

401No token, a malformed token, or a token this server did not issue.

The Error schema, in full at the foot of this page.

403Not an ADMIN token.

The Error schema, in full at the foot of this page.

422The request failed validation. `error.details` names the fields.

The Error schema, in full at the foot of this page.

429Rate limited. Retry after the window in the `RateLimit-*` headers.

The Error schema, in full at the foot of this page.

500Unhandled server error. Quote `error.request_id` in a support ticket.

The Error schema, in full at the foot of this page.

get/api/v1/integrations/{provider}/callbackNo token

The provider's redirect at the end of consent.

oauthCallback

Public by necessity. This is a top-level browser navigation from the provider's own domain: it carries no cookie of ours and no bearer token, and nothing about the request is under our control except the signed `state` we put into the authorize URL ten minutes earlier. That signature is the authentication.

Not for programmatic use, and the only endpoint in this API that answers with HTML — a human is looking at it. Every outcome including a refusal renders a page rather than an error envelope; pressing Deny is a completed flow with a no in it, not a validation failure.

Parameters

Parameters for GET /api/v1/integrations/{provider}/callback
FieldTypeRulesNotes
provider*"stripe" | "quickbooks" | "salesforce"path—
codestringquery, 1–2000 chars—
statestringquery, 1–2000 chars—
realmIdstringquery, 1–64 chars—
errorstringquery, max 200 chars—
error_descriptionstringquery, max 500 chars—

Responses

200Consent completed, or was declined. The page says which; the status does not.

Answers in text/html, not JSON.

400The `state` parameter was missing, expired, tampered with, or replayed.

Answers in text/html, not JSON.

502The provider rejected the code exchange.

Answers in text/html, not JSON.

post/api/v1/integrations/stripe/webhookNo token

Stripe's event delivery endpoint.

stripeWebhook

Public, and authenticated by an HMAC over the exact bytes Stripe sent — it has no credential of ours to present. The body is read raw for that reason: parsing and re-serialising JSON is not byte-identical to its input, so a handler working from a parsed object cannot verify anything.

Returns 200 for events it acts on and events it does not, distinguished by `handled`. Stripe retries any non-2xx with backoff for three days, so a 500 on an event type we have no handler for buys three days of retrying something that will never succeed. A failed signature is the one case where retrying is right, and the one case that answers 4xx.

`invoice.paid` sets `paid_at` on the originating quote, guarded on that column being null so a replayed delivery cannot rewrite the date a tenant was paid.

Parameters

Parameters for POST /api/v1/integrations/stripe/webhook
FieldTypeRulesNotes
stripe-signature*stringheaderStripe's `t=` timestamp and `v1=` HMAC, as sent.

Request body

A Stripe event object, delivered as raw bytes.

An object with no declared properties — the description above says what it carries.

Responses

200Acknowledged.
200 response for POST /api/v1/integrations/stripe/webhook
FieldTypeRulesNotes
received*true——
handled*boolean——
400Signature verification failed, or the body is not JSON.

The Error schema, in full at the foot of this page.

503STRIPE_WEBHOOK_SECRET is not set on this deployment.

The Error schema, in full at the foot of this page.

Shared schemas

Every failure takes the same shape

One envelope, one enumerated code list, and a request id on every response so a support conversation can start from a log line rather than a description of what happened.

Error

The shape every non-2xx response takes, without exception.

The Error schema
FieldTypeRulesNotes
error*objectno extra keys—
error.code*"bad_request" | "validation_failed" | "unauthenticated" | "invalid_credentials" | "forbidden" | "not_found" | "conflict" | "rate_limited" | "provider_error" | "internal_error"——
error.message*string——
error.detailsobjectnullable, no extra keysField path → the first thing wrong with it.
error.details.fields*object——
error.request_id*stringnullable—

ValidationIssues

Field path → the first thing wrong with it.

The ValidationIssues schema
FieldTypeRulesNotes
fields*object——

Reading it is one thing. Calling it is another.

The developer platform page carries the rate limits the server enforces, the webhook signature samples, the sandbox tenant and a runner that sends real requests against the host named above.

Developer platform