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.

  • 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.

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.

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

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

Billing

Subscriptions, invoices, PAD and EFT settlement.

QuickBooks

Accounting

Progress-billing invoices posted per job, with holdback and HST mapped to the line, not the total.

Xero

Accounting

Two-way chart-of-accounts reconciliation.

Sage 300 CRE

ERP

Job cost, commitments, and change orders — Sage stays authoritative once a CO is executed.

Twilio

Messaging

Outbound technician-ETA SMS with STOP handling.

Somfy

Hardware

RTS and Zigbee motor provisioning generated from the quote — channel, limits, and group written before the van loads.

Lutron

Hardware

Sivoia QS and Athena scene binding.

DALI Alliance

Hardware

Certification status checked against the product registry, so a spec cannot quote a driver that is only version-1 registered.

KNX

Hardware

Group-address export for commissioning.

Google Maps Platform

Geo

Distance matrix and traffic-profiled routing.

Salesforce

Workspace

Opportunity and quote records kept in step without a middleware project.

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