> For the complete documentation index, see [llms.txt](https://docs.novelsystems.ca/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://docs.novelsystems.ca/api-authentication-and-overview.md).

# API Authentication & Overview

Base endpoint, short-lived bearer token authentication, error format, conventions and rate limits.

The Novel Systems API is how commercial trade operators drive the CPQ engine and the field service module from their own systems — an ERP that needs a priced quote back, a dispatch board that needs job status, a scheduler that needs to know a work order was completed.

Everything below is REST over HTTPS, JSON in and JSON out, and scoped to a single tenant. Every endpoint documented here exists and answers; the machine-readable contract is [`openapi.json`](https://novelsystems.ca/openapi.json), which is generated from the server's own validation schemas rather than written alongside them.

### Base endpoint

```
https://novel-systems-backend.vercel.app
```

The version is part of the path, not the base — `/api/v1/quotes/calculate`, not `{base}/v1` plus `/quotes/calculate`. Concatenate the base with a documented path and you get a URL that resolves.

There is no separate sandbox host. The sandbox is a **seeded tenant on the same origin**, reached by sending its workspace name in the login body. Tenancy is a row-level property enforced by Postgres row-level security, so a sandbox workspace is isolated from a production one by the database, not by a deployment boundary. Ask for sandbox credentials and you get a workspace with a full SKU catalogue, labour tiers and demo users already in it.

### Authentication

There are no long-lived API keys. Authentication is a short-lived bearer token, obtained by posting credentials.

```http
POST /api/v1/auth/login
Content-Type: application/json

{
  "subdomain": "acme",
  "email": "sales@acme.example",
  "password": "..."
}
```

```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"
  }
}
```

Send it on every subsequent request:

```bash
curl https://novel-systems-backend.vercel.app/api/v1/work-orders \
  -H "Authorization: Bearer $NOVEL_ACCESS_TOKEN"
```

#### The workspace is part of the credential

`subdomain` is required and is not derived from the email address. Email addresses are unique *per tenant*, not globally, so one address can legitimately identify two people at two different contractors — and picking one of them for the caller would sometimes sign them into the wrong company. In a browser the field is filled from the hostname the user is already on, so nobody types it.

#### Token lifetime

`expires_in` is **900 seconds**, and it is a duration rather than an expiry instant — the client does the arithmetic. Re-post the credentials when it lapses.

A token is a session, not a secret to paste into a config file. The organization and role encoded in it are re-checked against the database on every request, so a revoked user stops working immediately rather than at the end of the token's life.

#### Failed authentication

```json
{
  "error": {
    "code": "invalid_credentials",
    "message": "Those credentials are not valid.",
    "details": null,
    "request_id": "8f0d1c2b-4e7a-4c31-9b06-5a2e1d94f7c3"
  }
}
```

Every failure mode answers identically — wrong password, unknown address, unknown workspace, disabled account. Do not branch on the message; the uniformity is deliberate, because a distinguishable "no such user" turns the login form into an account-enumeration oracle.

### Errors

Every non-2xx response, without exception, has this shape:

```json
{
  "error": {
    "code": "validation_failed",
    "message": "The request body did not validate.",
    "details": { "fields": { "lineItems.0.dropInches": "Expected number, received string" } },
    "request_id": "6d4b0a19-c2f7-4e88-a1d5-90fb3e27c04a"
  }
}
```

| Field        | Notes                                                                                                                                                                                                                                                       |
| ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `code`       | One of `bad_request`, `validation_failed`, `unauthenticated`, `invalid_credentials`, `forbidden`, `not_found`, `conflict`, `rate_limited`, `internal_error`. Branch on this, not on the status or the message.                                              |
| `message`    | Human-readable, and subject to change. Not a stable interface.                                                                                                                                                                                              |
| `details`    | Populated on `422` only, as `{ "fields": { … } }` keyed by dotted path — a bad second line item reads `lineItems.1.dropInches`. `null` on most other errors, and absent entirely on a router 404. A client reading this key has to handle all three states. |
| `request_id` | Echoed from an inbound `x-request-id` when you send one, generated otherwise. Quote it in a support ticket and the log line is findable.                                                                                                                    |

Only the first complaint per field is reported. Later issues on one field are usually consequences of the first.

### Conventions

| Convention     | Detail                                                                                                                                                                                                                                                                                         |
| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Content type   | `application/json` on request and response, errors included.                                                                                                                                                                                                                                   |
| Money          | **Integer cents.** `690280` is $6,902.80 CAD. Keys ending `_cents` are integers; there is no floating-point money anywhere in the API.                                                                                                                                                         |
| Other decimals | **Strings**, deliberately — `"0.5"`, `"94.75"`. The server does exact decimal arithmetic and a figure read back through `parseFloat` is a float again. Parse them into your language's decimal type, not its number type.                                                                      |
| Margins        | Decimal fractions, as strings. `"0.5"` is 50%. Never a percentage.                                                                                                                                                                                                                             |
| Timestamps     | ISO-8601, UTC, e.g. `2026-09-01T00:00:00.000Z`. Dates with no time component are `YYYY-MM-DD`.                                                                                                                                                                                                 |
| Pagination     | Cursor-based on `GET /api/v1/quotes`. The response carries `next_cursor`; pass it back as `?cursor=`. `null` means the page you have is the last one.                                                                                                                                          |
| Idempotency    | There is no `Idempotency-Key` header. Where an operation is idempotent it is idempotent by construction — re-sending a work order status that is already stored returns `200` with `"unchanged": true` rather than erroring, because a technician with intermittent signal will send it twice. |

### Rate limits

Two limiters, and these are the ones the server runs — not a plan allowance table.

| Applies to                      | Requests | Window         | Counted per                         |
| ------------------------------- | -------- | -------------- | ----------------------------------- |
| Every path except `/api/health` | 300      | per minute     | Client address, one proxy hop       |
| `POST /api/v1/auth/login`       | 10       | per 15 minutes | Workspace and email being attempted |

The broad ceiling is a stability measure rather than a security control: it stops one looping client saturating the process. Health checks are exempt so a monitoring probe cannot be starved by traffic.

The sign-in limiter is keyed on the account being attempted rather than the address attempting it, and that choice matters. A per-address limit protects the wrong thing — one contractor's office shares a single address, so it locks out a whole company when one person mistypes a password, while a spray of one password across thousands of accounts from a residential proxy pool never approaches the threshold. It is also counted *after* validation, so the key is the normalised identity the server will actually look up, not whatever whitespace the client happened to send.

Both limiters emit IETF draft-7 headers:

```http
RateLimit: limit=300, remaining=287, reset=42
RateLimit-Policy: 300;w=60
Retry-After: 42
```

The legacy `X-RateLimit-*` headers are explicitly disabled. Read your effective limit from the headers above rather than hard-coding the table, and on a `429` back off by `Retry-After` rather than retrying immediately.

### Where to go next

* **CPQ Engine API** — price a job and get the margin back before you commit to it.
* **FSM Field Sync API** — work order dispatch and status, and the webhooks that fire when something changes in the field.


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://docs.novelsystems.ca/api-authentication-and-overview.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
