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.
- 9
First-party integrations across 6 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.
Standard
- Req / min
- 1,200
- Burst
- 200
- Routes
- 4
Sufficient for nightly ERP reconciliation plus interactive quoting for a single branch.
Business
- Req / min
- 4,000
- Burst
- 800
- Routes
- 16
Sized for multi-branch quoting with live route re-optimisation through the working day.
Enterprise
- 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.
9 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
AccountingInvoice sync with HST line-item mapping.
Xero
AccountingTwo-way chart-of-accounts reconciliation.
Twilio
MessagingOutbound technician-ETA SMS with STOP handling.
Somfy
HardwareRTS and Zigbee motor provisioning payloads.
Lutron
HardwareSivoia QS and Athena scene binding.
KNX
HardwareGroup-address export for commissioning.
Google Maps Platform
GeoDistance matrix and traffic-profiled routing.
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