Developer platform & API infrastructure
The same engine that prices in the browser is reachable over REST. Versions are dates, webhooks are signed and at-least-once, and the sandbox is a seeded tenant rather than an empty schema. Current version 2026-07-01.
- 300
Requests per minute across the API, and the remainder is on every response — read from the limiter’s own configuration, not a sales figure.
- 3
Event families with published payload schemas and HMAC verification samples.
- 12
First-party integrations across 7 categories, no middleware tier required.
Keys are issued per integration by the Novel Systems platform team. Sandbox access is same-day.
The spec is generated, not written
The OpenAPI document is emitted from the same Zod validators the API parses requests with, so it cannot describe a field the service does not accept. A CI job re-generates it on every commit and fails the build if the committed copy differs, and a second job samples the deployed API against it — a contract that drifts stops the pipeline rather than reaching a reader.
- Browsable API referenceThe same document, rendered: every path with its fields, validation bounds, response shapes and error codes. Server HTML, no bundle to load./developers/reference
- OpenAPI 2026-07-01 documentEvery path, schema and error shape as JSON. Point Postman, an SDK generator, or your own client at it./openapi.json
- Narrative referenceAuthentication, pagination, idempotency and the money convention, written out rather than inferred from a schema.docs.novelsystems.ca
- Seeded sandbox tenantA tenant with a rate card, a catalogue and a dispatch board already in it, so the first call returns a priced job rather than an empty list.sandbox.novelsystems.ca
Base URL https://novel-systems-backend.vercel.app · versions are dates, and a version is only retired after it appears in the changelog below.
The endpoints above are tested, and the numbers are published
Pricing, tenancy isolation, webhook signing and the margin floor are covered by an automated suite that runs on every commit. What follows is the result of the last real run, not a target — the same artifact is fetchable as JSON, so the table can be checked rather than believed.
Generated from the run below by the script that ran it — /coverage.svg, not a third-party image.
- 97.96%
Line coverage
Against a build-failing gate of 90%. Every executable line in the pricing, tenancy and webhook modules.
- 87.87%
Branch coverage
Against a build-failing gate of 80%. Both sides of each conditional, including the margin-floor clamp.
- 94.40%
Function coverage
Against a build-failing gate of 90%. Exported and internal functions the suite actually calls.
409/409 tests passed across 50 suites, with 0 failing and 0 skipped, over 66 source files. Runner node v22.22.0 --test --experimental-test-coverage, invoked as npm --prefix backend run test:coverage.
Everything in this section is parsed out of one command’s output, and that output is published unedited: /test-run.txt is 2,892 lines of TAP with one ok per assertion, ending in the coverage table the percentages above come from. Its SHA-256 is recorded in the JSON artifact and re-checked on every build, so the log and the figures cannot be edited apart.
There is no link to a CI run here because the repository is private and the log would 404 for exactly the reader who wanted it. What is published instead is the pipeline itself — the workflow definitions, job by job — so “runs on every commit” can be read rather than taken on trust.
Where an API call actually goes
The same request path in one picture: browser or integrator system, through Cloudflare, into the Next.js frontend or straight at the Express API, through the middleware stack in the order it runs, across the row-level-security boundary into Postgres, and out to the three systems of record on approval.

quote.approved fans out to. 38 nodes and 54 edges, rendered from architecture.mmd — the source is published beside it, so the picture is checkable rather than something you take our word for.3 calls that cover most integrations
Pricing a scope of work and syncing the dispatch board are the two endpoints nearly every integration starts with. Responses carry the request id and the rate-limit remainder in headers, so you can instrument without a second call.
The workspace is part of the credential rather than something inferred from the address, because emails are unique per tenant and not globally. Returns a bearer token with a fifteen-minute life; every failure mode — wrong password, unknown address, unknown workspace, disabled account — answers identically, so the endpoint cannot be used to enumerate accounts.
Request · cURL
curl -X POST https://novel-systems-backend.vercel.app/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{
"subdomain": "acme",
"email": "sales@acme.example",
"password": "'"$NOVEL_PASSWORD"'"
}'Response
HTTP/1.1 200 OK
Content-Type: application/json
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"user": {
"id": "a0000001-0000-4000-8000-000000000002",
"email": "sales@acme.example",
"role": "SALES",
"organization_id": "11111111-1111-4111-8111-111111111111"
}
}Change a dimension and watch the cut list move
This is a real request to the pricing engine, made from your browser while you read. Tube length, hembar, fabric width and drop are computed on the server from the width and drop you type. Ask for a margin under the floor and the request is refused rather than quietly adjusted — the floor is a database constraint, and the engine would rather return a 400 than a quote nobody asked for.
Response
not sentBody sent
{
"laborTierCode": "GTA-STANDARD",
"lineItems": [
{
"reference": "Runner line 1",
"widthInches": 48,
"dropInches": 78,
"quantity": 6,
"fabricSkuCode": "FAB-SOLAR-3-CHARCOAL",
"hardwareSkuCode": "HW-BRACKET-SET"
}
]
}Body received
// Press “Send the request”.What this page is actually calling
Your browser posts to /api/cpq/calculate on this site. That route holds one sales-role credential for a seeded sandbox tenant, exchanges it for a fifteen-minute bearer token, forwards the validated body with persist forced to false, and hands back the upstream response untouched. It is a proxy so that a public page can make a real call without a token in it and without writing a row. The engine on the other side is the production one, and the two-step call below is what your own client would do.
# 1. Exchange credentials for a fifteen-minute bearer token.
TOKEN=$(curl -sX POST https://novel-systems-backend.vercel.app/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"subdomain":"acme","email":"sales@acme.example","password":"'"$NOVEL_PASSWORD"'"}' \
| jq -r .access_token)
# 2. The same call this page just made, against the API directly.
# `persist` is yours to set here; the proxy above forces it false.
curl -X POST https://novel-systems-backend.vercel.app/api/v1/quotes/calculate \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"laborTierCode":"GTA-STANDARD","lineItems":[{"reference":"Runner line 1","widthInches":48,"dropInches":78,"quantity":6,"fabricSkuCode":"FAB-SOLAR-3-CHARCOAL","hardwareSkuCode":"HW-BRACKET-SET"}]}'Events you can rebuild state from
Each event carries a stable id, the API version it was serialised under, and — where it is meaningful — previousAttributes, so a subscriber can diff without re-fetching the object.
quote.approvedThe moment a quote stops being a working document and becomes a commitment. Carries the committed totals in integer cents and the realised margin as a decimal string, so an accounting package can be posted without anyone rekeying a figure.Emitted by POST /api/v1/quotes/{id}/approve, after the transaction commits. Approving an already-approved quote returns 409 rather than firing a second time — a tenant's accounting integration raising two invoices for one job is worse than an error.
Payload
{
"event": "quote.approved",
"id": "0f3a9c21-6b48-4d0e-8f77-2a19c5d3b7e0",
"organization_id": "11111111-1111-4111-8111-111111111111",
"created_at": "2026-08-29T09:14:52.118Z",
"data": {
"quote_id": "3f9c0f1a-52b0-4a3f-9a0c-71bd0f4c9a12",
"quote_number": 41,
"status": "APPROVED",
"total_amount_cents": 690280,
"total_cost_cents": 345140,
"margin_percent": "0.5",
"customer_name": "Wellington Property Group",
"customer_email": "facilities@wellington.example",
"approved_at": "2026-08-29T09:14:52.104Z",
"approved_by_user_id": "a0000001-0000-4000-8000-000000000002"
}
}work_order.dispatchedA crew is assigned and the job is released to the field. Note what is absent: no cost, no margin, no quote total. Work-order events are reachable by the same systems that serve technicians, so they carry no commercial figures.Emitted by PATCH /api/v1/work-orders/{id}/status on a real transition only. A replayed status — the mobile client retrying on flaky LTE — returns 200 with `unchanged: true` and fires nothing.
Payload
{
"event": "work_order.dispatched",
"id": "b1d7e402-9c35-4f8a-a0d2-6e41f7c92a55",
"organization_id": "11111111-1111-4111-8111-111111111111",
"created_at": "2026-09-01T12:31:07.442Z",
"data": {
"work_order_id": "c8797ddf-7a62-4e97-9c92-b25096992b00",
"status": "DISPATCHED",
"quote_id": "3f9c0f1a-52b0-4a3f-9a0c-71bd0f4c9a12",
"assigned_technician_id": "a0000001-0000-4000-8000-000000000003",
"changed_by_user_id": "a0000001-0000-4000-8000-000000000001",
"changed_at": "2026-09-01T12:31:07.440Z"
}
}work_order.completedFinal sign-off. The completion timestamp is set by the transition rather than taken from the request body — a completion time supplied by the client is a completion time a technician can backdate, and these rows feed billing.Emitted on the transition to COMPLETED, which is a terminal status. `work_order.pending` and `work_order.cancelled` fire from the same handler on their own transitions.
Payload
{
"event": "work_order.completed",
"id": "7c2b5ae8-30f1-4b96-9d84-1fa60c73e2d9",
"organization_id": "11111111-1111-4111-8111-111111111111",
"created_at": "2026-09-01T21:05:33.907Z",
"data": {
"work_order_id": "c8797ddf-7a62-4e97-9c92-b25096992b00",
"status": "COMPLETED",
"quote_id": "3f9c0f1a-52b0-4a3f-9a0c-71bd0f4c9a12",
"assigned_technician_id": "a0000001-0000-4000-8000-000000000003",
"changed_by_user_id": "a0000001-0000-4000-8000-000000000003",
"changed_at": "2026-09-01T21:05:33.905Z"
}
}Delivery guarantees
- Delivery is at-least-once. Key the handler on X-Novel-Delivery, which carries the envelope's `id` and is stable across retries of one delivery, and ignore repeats.
- Respond 2xx within 8 seconds. The request is aborted at that point and the attempt counts as failed.
- Reject a delivery whose `t` is more than 5 minutes old. That is what stops a captured payload being replayed at you later.
- Retries are 5xx, 408 and 429 only. A 400 or a 404 means your endpoint has rejected the event on its merits, and sending it four more times produces four more rejections.
- 5 attempts in total, backing off 2ⁿ seconds with jitter — roughly a minute end to end, not a day. Plan a reconciliation pull for anything longer; the retry queue is not designed to bridge an outage.
- Redirects are not followed, and destinations resolving to private addresses are refused. A 302 to an internal host would otherwise defeat the destination check.
- Ordering is not guaranteed across event types. Every envelope carries created_at.
Verify before you trust a payload
Every delivery carries a X-Novel-Signature header containing a timestamp and an HMAC-SHA256 digest over `${timestamp}.${rawBody}`. Reject anything older than 300 seconds, and compare in constant time.
X-Novel-Signature: t=1785424088,v1=5f8c2e1b9a7d4c3e6b0f2a8d1c4e7b9f0a3d6c2e5b8f1a4d7c0e3b6f9a2d5c8e
X-Novel-Delivery: 0f3a9c21-6b48-4d0e-8f77-2a19c5d3b7e0
X-Novel-Event: quote.approvedNode.js
import crypto from "node:crypto";
const TOLERANCE_SECONDS = 300;
export function verifyNovelSignature(
rawBody: string,
header: string,
secret: string,
): boolean {
const parts = new Map(
header.split(",").map((pair) => {
const [key, value] = pair.split("=");
return [key ?? "", value ?? ""] as const;
}),
);
const timestamp = Number(parts.get("t"));
const signature = parts.get("v1");
if (!Number.isFinite(timestamp) || signature === undefined) return false;
// Reject replays before spending any time on the digest.
const ageSeconds = Math.abs(Date.now() / 1000 - timestamp);
if (ageSeconds > TOLERANCE_SECONDS) return false;
const expected = crypto
.createHmac("sha256", secret)
.update(`${timestamp}.${rawBody}`)
.digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(signature, "utf8");
if (a.length !== b.length) return false;
// timingSafeEqual, not ===. String comparison leaks the prefix length.
return crypto.timingSafeEqual(a, b);
}Python
import hashlib
import hmac
import time
TOLERANCE_SECONDS = 300
def verify_novel_signature(raw_body: bytes, header: str, secret: str) -> bool:
parts = dict(
pair.split("=", 1) for pair in header.split(",") if "=" in pair
)
try:
timestamp = int(parts["t"])
signature = parts["v1"]
except (KeyError, ValueError):
return False
# Reject replays before spending any time on the digest.
if abs(time.time() - timestamp) > TOLERANCE_SECONDS:
return False
signed_payload = f"{timestamp}.".encode() + raw_body
expected = hmac.new(
secret.encode(), signed_payload, hashlib.sha256
).hexdigest()
# compare_digest, not ==. String comparison leaks the prefix length.
return hmac.compare_digest(expected, signature)Limits published, not discovered under load
Two limiters, both of them real. One broad ceiling across the API, and one on sign-in keyed to the account being attempted rather than the address attempting it. Every response carries the remainder in a standard RateLimit header, so a well-behaved client throttles itself instead of finding the wall.
Every path except /api/health
- Requests
- 300 per minute
- Counted per
- Client address, one proxy hop
A ceiling rather than a security control: it stops one looping client saturating the process. Health checks are exempt so a probe cannot be starved by traffic.
POST /api/v1/auth/login
- Requests
- 10 per 15 minutes
- Counted per
- Workspace and email being attempted
Keyed on the account under attack, not the caller's address — an office shares one address, and a password spray does not. Counted after validation, so the key is the normalised identity the controller will look up.
Every response carries
RateLimit: limit=300, remaining=287, reset=42
RateLimit-Policy: 300;w=60
Retry-After: 42Sandbox tenant
Bearer <token>https://novel-systems-backend.vercel.appThe seeded workspace: a full SKU catalogue, labour tiers and demo users. Reached by sending its subdomain in the login body — the same host, a different tenant. Nothing written here is visible to any other workspace, because the isolation is enforced by row-level security rather than by convention.
Production
Bearer <token>https://novel-systems-backend.vercel.appYour own workspace on the same origin. Every query runs under a tenant-scoped Postgres role, so a bug in application code cannot read across the boundary. Tokens expire in fifteen minutes and carry the workspace they were issued for; there is nothing to rotate and nothing to leak long-term.
12 first-party integrations
Each one is maintained against the vendor’s current API rather than routed through a generic middleware tier, which is why hardware addressing and accounting sync behave like features instead of like adapters.
Stripe
BillingApproved quotes finalised as invoices, or paid on a hosted card page. Card only, by design.
QuickBooks
AccountingProgress-billing invoices posted per job, with holdback and HST mapped to the line, not the total.
Xero
AccountingTwo-way chart-of-accounts reconciliation.
Sage 300 CRE
ERPJob cost, commitments, and change orders — Sage stays authoritative once a CO is executed.
Twilio
MessagingOutbound technician-ETA SMS with STOP handling.
Somfy
HardwareRTS and Zigbee motor provisioning generated from the quote — channel, limits, and group written before the van loads.
Lutron
HardwareSivoia QS and Athena scene binding.
DALI Alliance
HardwareCertification status checked against the product registry, so a spec cannot quote a driver that is only version-1 registered.
KNX
HardwareGroup-address export for commissioning.
Google Maps Platform
GeoDistance matrix and traffic-profiled routing.
Salesforce
WorkspaceOpportunity and quote records kept in step without a middleware project.
Slack
WorkspaceJob escalations routed to the dispatch channel.
Versions are dates, and old ones stay live
A breaking change ships as a new dated version. The version you pinned to remains available for twelve months, so an upgrade is something you schedule rather than something that happens to you.
- deprecated
2026-07-01July 1, 2026CurrentGraphQL schema expansion. Scalar lineItems[].deduction. Route responses name the constraint that excluded a technician.
- added
2026-04-15April 15, 2026pricebookRevision on quote responses.
- changed
2026-01-20January 20, 2026technicianId renamed to assigneeId on dispatch payloads. partsConsumed on invoice.generated.
- deprecated
2025-10-08October 8, 2025v0 /estimates superseded by /v1/quotes.
- added
2025-06-03June 3, 2025REST API v1 and signed webhooks.
Get a sandbox workspace today
The sandbox arrives seeded — a full catalogue, labour tiers, technicians and working webhooks — so the first thing you write is your integration rather than fixtures. It is a tenant on https://novel-systems-backend.vercel.app, reached by sending its workspace name when you sign in.
Current API version 2026-07-01 · Canadian residency, ca-central-1