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

Endpoints

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

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

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

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: 42
  • Sandbox tenant

    Bearer <token>
    https://novel-systems-backend.vercel.app

    The 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.app

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

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