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.
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"
}
}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
BillingSubscriptions, invoices, PAD and EFT settlement.
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