Developer platform & API infrastructure
The same engine that prices in the browser is reachable over REST and GraphQL. 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.
- 10,000
Requests per minute on Enterprise — a contractual floor, not a shared-pool ceiling.
- 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.
2 calls that cover most integrations
Creating a configured quote and optimising a dispatch route are the two endpoints nearly every integration starts with. Responses carry the engine latency and the rate-limit remainder in headers, so you can instrument without a second call.
Posts a configuration and receives a priced quote with the derived cut list, the price book revision it was priced against, and the engine latency in a response header.
Request · cURL
curl -X POST https://api.novelsystems.ca/v1/quotes \
-H "Authorization: Bearer $NOVEL_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: 8f2c1a90-quote-create" \
-d '{
"customerId": "cus_7Q4M2N",
"currency": "CAD",
"taxRegion": "ON",
"lines": [
{
"sku": "ROLLER-5PCT-ALU",
"quantity": 12,
"configuration": {
"finishedWidthIn": 47.25,
"finishedHeightIn": 62.5,
"controlSide": "right",
"motorised": true,
"motorSku": "SOMFY-SONESSE-30"
}
}
],
"pricebookId": "pb_contract_2026_q3"
}'Response
HTTP/1.1 201 Created
X-Request-Id: req_01J8Q4M2N7
X-RateLimit-Remaining: 9987
X-Engine-Latency-Ms: 11.6
{
"id": "qte_01J8Q4M2N7",
"reference": "NS-Q-26-0481",
"status": "draft",
"revision": 1,
"currency": "CAD",
"pricebookId": "pb_contract_2026_q3",
"pricebookRevision": "2026-07-01T00:00:00Z",
"totals": {
"subtotal": 1136.46,
"taxTotal": 147.74,
"grandTotal": 1284.20,
"marginPct": 0.412,
"currency": "CAD"
},
"lines": [
{
"sku": "ROLLER-5PCT-ALU",
"quantity": 12,
"unitPrice": 94.71,
"extendedPrice": 1136.46,
"cutList": {
"tubeIn": 45.5625,
"hembarIn": 45.3125,
"fabricIn": 46.75
},
"marginPct": 0.412
}
],
"createdAt": "2026-07-30T14:22:08.412Z"
}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.createdA quote reached a persisted revision. Every revision fires, so a subscriber can reconstruct exactly what a customer was shown on any given day.Emitted on the server-authoritative commit, after margin and approval evaluation.
Payload
{
"id": "evt_01J8Q4MB2R",
"type": "quote.created",
"apiVersion": "2026-07-01",
"createdAt": "2026-07-30T14:22:08.517Z",
"livemode": true,
"data": {
"object": {
"id": "qte_01J8Q4M2N7",
"reference": "NS-Q-26-0481",
"revision": 1,
"status": "draft",
"customerId": "cus_7Q4M2N",
"ownerId": "usr_4820",
"currency": "CAD",
"totals": {
"subtotal": 1136.46,
"taxTotal": 147.74,
"grandTotal": 1284.20,
"marginPct": 0.412
},
"pricebookRevision": "2026-07-01T00:00:00Z",
"approval": {
"required": false,
"reason": null
}
},
"previousAttributes": null
}
}dispatch.updatedA route or an assignment changed. The payload carries previousAttributes so a subscriber can diff without re-fetching the route.Emitted on reassignment, resequencing, ETA drift beyond the configured threshold, or a technician status change.
Payload
{
"id": "evt_01J8Q4MC7X",
"type": "dispatch.updated",
"apiVersion": "2026-07-01",
"createdAt": "2026-08-04T09:03:41.220Z",
"livemode": true,
"data": {
"object": {
"id": "rte_01J8Q4M9TT",
"date": "2026-08-04",
"technicianId": "tech_ry",
"totalDriveMinutes": 148,
"overtimeMinutes": 12,
"changeReason": "job_9815_window_moved",
"stops": [
{
"sequence": 2,
"jobId": "job_9815",
"etaLocal": "2026-08-04T11:10:00-04:00",
"status": "en_route"
}
]
},
"previousAttributes": {
"totalDriveMinutes": 132,
"overtimeMinutes": 0
}
}
}invoice.generatedA completed job or accepted quote produced a draft invoice, with parts consumed on the van already reflected as lines.Emitted after job completion sync reconciles, or immediately on quote acceptance for non-field orders.
Payload
{
"id": "evt_01J8Q4MD9K",
"type": "invoice.generated",
"apiVersion": "2026-07-01",
"createdAt": "2026-08-04T16:48:02.905Z",
"livemode": true,
"data": {
"object": {
"id": "inv_01J8Q4MD9K",
"reference": "NS-I-26-1174",
"sourceType": "job",
"sourceId": "job_9812",
"quoteId": "qte_01J8Q4M2N7",
"customerId": "cus_7Q4M2N",
"currency": "CAD",
"status": "draft",
"totals": {
"subtotal": 1136.46,
"taxTotal": 147.74,
"grandTotal": 1284.20
},
"taxLines": [
{ "code": "HST-ON", "rate": 0.13, "amount": 147.74 }
],
"partsConsumed": [
{ "sku": "PSU-24V-100W", "quantity": 2, "vanId": "van_ry_01" }
],
"erpSync": {
"target": "quickbooks",
"status": "queued"
}
},
"previousAttributes": null
}
}Delivery guarantees
- Delivery is at-least-once. Handlers must be idempotent on the event id, which is stable across retries.
- Retries back off exponentially for 24 hours: 8 attempts, starting at 10 seconds. A 2xx within 300 seconds of dispatch is the only acknowledgement.
- Ordering is not guaranteed across event types. Every payload carries createdAt and, where meaningful, a monotonic revision.
- An endpoint failing every attempt for 72 hours is disabled and the owner is emailed. Disabled endpoints replay on re-enable.
Verify before you trust a payload
Every delivery carries a 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.
Novel-Signature: t=1785424088,v1=5f8c2e1b9a7d4c3e6b0f2a8d1c4e7b9f0a3d6c2e5b8f1a4d7c0e3b6f9a2d5c8eNode.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
Limits are per-tenant token buckets. Burst is the depth; the per-minute figure is the refill. Every response carries the remainder, so a well-behaved client throttles itself instead of finding the wall.
Starter Operator
- Req / min
- 1,200
- Burst
- 200
- Routes
- 4
Sufficient for nightly ERP reconciliation plus interactive quoting for a single branch.
Growth Contractor
- Req / min
- 4,000
- Burst
- 800
- Routes
- 16
Sized for multi-branch quoting with live route re-optimisation through the working day.
Enterprise Fleet
- Req / min
- 10,000
- Burst
- 2,500
- Routes
- 64
Dedicated cluster. Limits are a contractual floor rather than a shared-pool ceiling.
Every response carries
X-RateLimit-Limit: 10000
X-RateLimit-Remaining: 9987
X-RateLimit-Reset: 1785424140
Retry-After: 3Sandbox
nsk_test_…https://sandbox.api.novelsystems.ca/v1A seeded tenant with a full catalogue, ten technicians, and a rate table. Data resets weekly; no email or ERP writes leave the environment.
Production
nsk_live_…https://api.novelsystems.ca/v1Canadian residency, ca-central-1. Keys are scoped per integration and every call is attributed to one in the audit log.
GraphQL
nsk_live_…https://api.novelsystems.ca/graphqlOne endpoint over the same engine. The playground is authenticated with a test key and introspection is enabled in sandbox only.
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 key today
The sandbox arrives seeded — a full catalogue, ten technicians, a rate table, and working webhooks — so the first thing you write is your integration rather than fixtures. The GraphQL playground is at https://api.novelsystems.ca/graphql.
Current API version 2026-07-01 · Canadian residency, ca-central-1