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.

Machine-readable contract

The spec is generated, not written

The OpenAPI document is emitted from the same Zod validators the API parses requests with, so it cannot describe a field the service does not accept. A CI job re-generates it on every commit and fails the build if the committed copy differs, and a second job samples the deployed API against it — a contract that drifts stops the pipeline rather than reaching a reader.

Base URL https://novel-systems-backend.vercel.app · versions are dates, and a version is only retired after it appears in the changelog below.

Verification

The endpoints above are tested, and the numbers are published

Pricing, tenancy isolation, webhook signing and the margin floor are covered by an automated suite that runs on every commit. What follows is the result of the last real run, not a target — the same artifact is fetchable as JSON, so the table can be checked rather than believed.

Coverage badge: 97.96% line coverage on the backend test suite

Generated from the run below by the script that ran it — /coverage.svg, not a third-party image.

409/409 tests passed across 50 suites, with 0 failing and 0 skipped, over 66 source files. Runner node v22.22.0 --test --experimental-test-coverage, invoked as npm --prefix backend run test:coverage.

Everything in this section is parsed out of one command’s output, and that output is published unedited: /test-run.txt is 2,892 lines of TAP with one ok per assertion, ending in the coverage table the percentages above come from. Its SHA-256 is recorded in the JSON artifact and re-checked on every build, so the log and the figures cannot be edited apart.

There is no link to a CI run here because the repository is private and the log would 404 for exactly the reader who wanted it. What is published instead is the pipeline itself — the workflow definitions, job by job — so “runs on every commit” can be read rather than taken on trust.

Where an API call actually goes

The same request path in one picture: browser or integrator system, through Cloudflare, into the Next.js frontend or straight at the Express API, through the middleware stack in the order it runs, across the row-level-security boundary into Postgres, and out to the three systems of record on approval.

Request-flow architecture diagram. Anonymous visitors, authenticated operators, technicians on mobile and integrator systems enter through Cloudflare DNS. The Next.js frontend on Vercel serves marketing pages and the browser CPQ engine, and proxies priced quotes to the Express API with a server-side credential. The API runs helmet, CORS allowlist, raw and JSON body parsers and a 300-per-minute rate limiter in that order, then requireAuth and requireRole before the quotes, work-orders and integrations routers. Every database query passes through withTenant(), which sets a per-transaction tenant identifier, and then through row-level security on Postgres 15 in ca-central-1, where every table carries a per-tenant policy. Approving a quote fires both the webhook dispatcher and the integration fan-out to Stripe, QuickBooks Online and Salesforce. Inbound Stripe webhooks are authenticated by signature and resolved to a tenant through a single SECURITY DEFINER function.
Clients through Cloudflare into two Vercel projects, every query crossing the row-level-security boundary inside a transaction that has declared its tenant, and the three system-of-record providers that quote.approved fans out to. 38 nodes and 54 edges, rendered from architecture.mmd — the source is published beside it, so the picture is checkable rather than something you take our word for.
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"
  }
}
Run it

Change a dimension and watch the cut list move

This is a real request to the pricing engine, made from your browser while you read. Tube length, hembar, fabric width and drop are computed on the server from the width and drop you type. Ask for a margin under the floor and the request is refused rather than quietly adjusted — the floor is a database constraint, and the engine would rather return a 400 than a quote nobody asked for.

Request

Response

not sent

Body sent

{
  "laborTierCode": "GTA-STANDARD",
  "lineItems": [
    {
      "reference": "Runner line 1",
      "widthInches": 48,
      "dropInches": 78,
      "quantity": 6,
      "fabricSkuCode": "FAB-SOLAR-3-CHARCOAL",
      "hardwareSkuCode": "HW-BRACKET-SET"
    }
  ]
}

Body received

// Press “Send the request”.

What this page is actually calling

Your browser posts to /api/cpq/calculate on this site. That route holds one sales-role credential for a seeded sandbox tenant, exchanges it for a fifteen-minute bearer token, forwards the validated body with persist forced to false, and hands back the upstream response untouched. It is a proxy so that a public page can make a real call without a token in it and without writing a row. The engine on the other side is the production one, and the two-step call below is what your own client would do.

# 1. Exchange credentials for a fifteen-minute bearer token.
TOKEN=$(curl -sX 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"'"}' \
  | jq -r .access_token)

# 2. The same call this page just made, against the API directly.
#    `persist` is yours to set here; the proxy above forces it false.
curl -X POST https://novel-systems-backend.vercel.app/api/v1/quotes/calculate \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"laborTierCode":"GTA-STANDARD","lineItems":[{"reference":"Runner line 1","widthInches":48,"dropInches":78,"quantity":6,"fabricSkuCode":"FAB-SOLAR-3-CHARCOAL","hardwareSkuCode":"HW-BRACKET-SET"}]}'
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

Approved quotes finalised as invoices, or paid on a hosted card page. Card only, by design.

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