Skip to content
Skip to main content
Novel Systems home
Developer platform

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.

Endpoints

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"
}
Webhook & event engine

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.
Signature verification

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=5f8c2e1b9a7d4c3e6b0f2a8d1c4e7b9f0a3d6c2e5b8f1a4d7c0e3b6f9a2d5c8e

Node.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)
Rate limits & environments

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: 3
  • Sandbox

    nsk_test_
    https://sandbox.api.novelsystems.ca/v1

    A 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/v1

    Canadian 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/graphql

    One endpoint over the same engine. The playground is authenticated with a test key and introspection is enabled in sandbox only.

Integrations

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

Billing

Subscriptions, invoices, PAD and EFT settlement.

QuickBooks

Accounting

Invoice sync with HST line-item mapping.

Xero

Accounting

Two-way chart-of-accounts reconciliation.

Twilio

Messaging

Outbound technician-ETA SMS with STOP handling.

Somfy

Hardware

RTS and Zigbee motor provisioning payloads.

Lutron

Hardware

Sivoia QS and Athena scene binding.

KNX

Hardware

Group-address export for commissioning.

Google Maps Platform

Geo

Distance matrix and traffic-profiled routing.

Slack

Workspace

Job escalations routed to the dispatch channel.

Changelog

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.

  1. deprecated2026-07-01July 1, 2026Current

    GraphQL schema expansion. Scalar lineItems[].deduction. Route responses name the constraint that excluded a technician.

  2. added2026-04-15April 15, 2026

    pricebookRevision on quote responses.

  3. changed2026-01-20January 20, 2026

    technicianId renamed to assigneeId on dispatch payloads. partsConsumed on invoice.generated.

  4. deprecated2025-10-08October 8, 2025

    v0 /estimates superseded by /v1/quotes.

  5. added2025-06-03June 3, 2025

    REST 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