NEXUS by Wash Associates
Sign in

NEXUS External KPI API — Developer Guide

A read-only, machine-to-machine REST API for pulling KPI data out of the NEXUS Dashboard into another application. Every value comes with provenance (which connector produced it) and freshness (when that connector last synced).

This guide is self-contained. It is written to be handed directly to a developer — or to an LLM acting as one — with no access to the NEXUS repository. Base URL, auth, scopes, every endpoint, every error, and a client you can run.

Where this guide and the committed OpenAPI document disagree, the running code is authoritative and this guide tracks the code.


Table of contents

  1. What this API is, and what it is not
  2. Base URL and versioning
  3. Authentication
  4. Scopes
  5. Rate limits
  6. Endpoint reference
  7. The demand heat-map
  8. Freshness and provenance
  9. Errors
  10. Limits and quotas
  11. Worked example: a Python client
  12. Changelog and migration notes

1. What this API is, and what it is not

What it is. A GET-only surface over the KPI warehouse behind the NEXUS executive dashboard. It serves aggregated KPI values, raw daily series, wide bulk pulls, an hourly demand grid, and connector health — each annotated with where the number came from and when that source last succeeded.

What it is not:

  • It is not writable. All nine routes are declared methods=['GET']. There is no POST, PATCH, PUT or DELETE anywhere under /api/external/v1, so a leaked key can only read. Every route accepts GET and, because of the framework's routing, HEAD — Werkzeug adds HEAD to any rule declaring GET. A HEAD request executes the full handler and is charged the full endpoint cost (it also takes the heavy-read slot and can return 503 BUSY), so do not use it for liveness probing; use GET /health, cost 1. OPTIONS returns 401. Every other method returns 405 METHOD_NOT_ALLOWED.
  • It is not a browser API. There is no CORS handling. OPTIONS on any path returns 401 before routing, so a browser preflight can never succeed. Call it server-to-server, and never ship the token to a client device.
  • It is not the dashboard's internal API. /api/v1/* is session- and cookie-authenticated and is not covered here. An API key cannot call it.
  • It is not a live event stream. Data lands when connectors sync (typically daily; the hourly demand connector runs every 12 hours). Polling faster than the sync cadence buys you nothing but rate-limit spend.
  • It is not an admin surface. Keys are minted in the dashboard UI, not through this API.

2. Base URL and versioning

https://rapid-elt.azurewebsites.net/api/external/v1

That is the production base URL, and it is what the committed OpenAPI document's servers[0] declares, so a generated client connects there without patching. If you were issued a key for a different deployment, substitute that host; the path suffix is identical.

Every path in this guide is relative to that base. The v1 in the path is the contract version; a breaking change would ship as v2 on a new prefix rather than mutating this one. Additive changes (new fields, new endpoints, new optional parameters) happen inside v1write your client to ignore unknown JSON fields.

A machine-readable OpenAPI 3.1 document is served at:

GET /api/external/v1/openapi.json

It requires a valid API key like every other route. Feed it to a codegen tool to generate a typed client. If the spec and this guide disagree on a detail, prefer this guide.

Response conventions

Thing Convention
Content type application/json on every response, success and error alike
Dates YYYY-MM-DD, inclusive on both ends of a range
generated_at, server_time UTC, second precision, Z-suffixed: 2026-08-10T17:52:46Z
last_synced_at, last_success_at raw database values — naive UTC with no Z. See §8.5 before parsing one
Error envelope {"error": "<human message>", "code": "<MACHINE_CODE>"} — always both fields

Branch on code, never on the error string. Message wording is not part of the contract; codes are.

2.1 Dates, times and timezones

Three different clocks are in play server-side, and they are not interchangeable. This table is the whole story:

Thing Clock it resolves in Notes
period keywords (today, mtd, last_month, …) the process timezone, via a naive datetime.now() On the production Azure host nothing pins WEBSITE_TIME_ZONE or TZ, so the container runs UTC and period=today is the UTC day. It is not configurable by you, and a self-hosted deployment could differ — which is why explicit dates are the robust choice.
Heat-map default window, and generated_at / server_time UTC The heat-map default is today (UTC) − 12 weeks … today (UTC). generated_at is always Z-suffixed UTC.
Heat-map open_hour / close_hour server-local today, 180-day lookback Derived from observed activity, independent of your requested range.
Freshness staleness comparison UTC (utcnow() vs the stored naive-UTC timestamp) See §8.5.
A daily date on /kpis/bulk, /kpis/{id}/timeseries, and the bounds echoed in period / range neither — see right A date value is the calendar day as the upstream source system reported it: the vendor's own business day for that site, effectively site-local. NEXUS stores the string as given and applies no conversion on read. A 2026-07-01 row is that site's business day, not a UTC day.
Heat-map hour (and its date) site-local The connector converts on write. Do not apply a UTC offset.

Recommendation: send explicit start_date / end_date everywhere. They are unambiguous, they are the only option on three of the five data endpoints anyway, and they take precedence over period where both are accepted.


3. Authentication

3.1 The two accepted headers

Every request must carry an API key. Two headers are accepted on every route:

Authorization: Bearer nxk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
X-API-Key: nxk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX

Authorization: Bearer is preferred and wins if both are sent. Three parsing details that bite people:

  • The Bearer prefix match is case- and space-sensitive. bearer <token> does not match, and the request silently falls through to X-API-Key.
  • Authorization: Bearer with an empty value also falls through to X-API-Key rather than erroring.
  • If neither header yields a value you get "missing token", not "malformed token".

3.2 Token format

Property Value
Prefix nxk_live_
Body 43 URL-safe base64 characters ([A-Za-z0-9_-]), 256 bits of entropy
Total length 52 characters
Server-side storage SHA-256 hex digest only — the plaintext is never persisted
Display prefix first 13 characters (nxk_live_ + 4), non-secret, shown in the admin list and usage log

3.3 Obtaining a key, and the show-once token

Keys are created by a NEXUS administrator under Admin → API Keys. At creation the admin chooses the key's name, its four scope dimensions (§4), an optional expiry, and optional per-key rate-limit overrides.

The full token is displayed exactly once, in the creation modal. Only a SHA-256 hash is stored server-side. There is no "show token again" and no recovery path. If it is lost, the only remedy is to revoke the key and mint a new one.

Copy it straight into your secret store. Do not paste it into a ticket, a chat message, or a config file in version control.

(Administrators: the backing endpoint POST /api/v1/admin/api-keys is a session-authenticated internal route, not part of this API. It supports an Idempotency-Key header, which is silently truncated to its first 128 characters — never rejected — so keep keys shorter than that: two keys sharing a 128-character prefix are treated as the same key and the second is handled as a replay or an IDEMPOTENCY_CONFLICT. Minting is not safely retryable: every successful POST creates a live credential whose plaintext is shown once, so a double-submit leaves a working key nobody holds and nobody knows to revoke. A replay with the same key and same body replays the original 201; a same-key/different-body replay is rejected with 409 IDEMPOTENCY_CONFLICT. Independently of that header, a same-name/same-scope mint by the same admin within 60 seconds is refused with 409 DUPLICATE_RECENTincluding when a new Idempotency-Key is supplied, since the duplicate guard runs unconditionally after the idempotency claim.)

3.4 Expiry

A key may carry an expiry, normalized to naive UTC at write time:

Admin supplies Stored as Meaning
nothing / empty NULL never expires
2026-12-31 (bare date) 2026-12-31T23:59:59 valid through the whole of that day, UTC
2026-12-31T09:00:00Z 2026-12-31T09:00:00 that instant
2026-12-31T09:00:00-05:00 2026-12-31T14:00:00 converted to UTC
2026-12-31T09:00:00 (naive) verbatim interpreted as UTC
an unparseable stored value verbatim treated as no expiry — fail-open. The comparison swallows the parse error and reports "not expired". _normalize_expiry is the only writer, so this should not arise through the UI, but it is a security control that fails open. If a key that should have expired is still working, have an admin re-save the expiry rather than assuming the comparison is off by a timezone.

Expiry is evaluated live from the database on every single request — there is no caching — so shortening or extending it takes effect on the next call. The comparison is <=, so the key is dead at the stored instant, not one second after. An expired key returns 401 with "API key has expired."

The admin UI's date picker enforces a minimum of the admin's local today while the stored value is UTC, so near a date boundary the effective expiry can be up to a day away from local intuition. If precision matters, have the admin set an explicit timestamp.

3.5 Disable vs revoke

These look identical from the client side and are very different on the server.

Operation Effect Reversible?
Disable (pause) is_active = 0 Yes — an admin can re-enable it
Revoke (kill) is_active = 0 and revoked_at stamped No — there is no code path that clears revoked_at; re-enabling a revoked key still yields status revoked

Revocation is a soft delete: the row, its usage history and its audit trail survive. Revoking an already-revoked key is idempotent.

Both produce the same 401 message to the client. A caller cannot distinguish "paused for a maintenance window" from "killed forever", and must not build logic that tries to. Treat any 401 as terminal and escalate to a human.

3.6 Rotation

There is no built-in rotation primitive. Rotate by overlap:

  1. Have an admin mint the replacement with identical scope on all four dimensions and identical rate-limit overrides. Give it a distinguishable name — an identical name + identical scope within 60 seconds is refused by the duplicate guard.
  2. Copy the token out of the show-once modal into your secret store immediately.
  3. Deploy the new token to every consumer. Budgets are per key, so during overlap you have two independent budgets, not a shared one — running both creates no throttling risk.
  4. Confirm traffic has moved: check the old key's last_used_at / request_count in Admin → API Keys. Usage rows are pruned after 90 days, so verify the cutover inside that window.
  5. Disable the old key first (reversible). If nothing breaks for a day or two, revoke it.

For a scheduled rotation, set expires_at on the outgoing key a few days after the planned cutover instead of revoking by hand — expiry is enforced on every request with no further admin action.

3.7 Every 401 variant

All five share HTTP 401 and "code": "UNAUTHORIZED". The error string is the only differentiator, and you should not string-match it:

Situation error
Neither header present (or both empty) Missing API key. Send "Authorization: Bearer <key>".
Token present but no such key Invalid or missing API key.
Key past its expires_at API key has expired.
Key disabled or revoked API key has been revoked or disabled.
Any OPTIONS request, to any path Missing API key. Send "Authorization: Bearer <key>".
{ "error": "API key has expired.", "code": "UNAUTHORIZED" }

None of these is transient. Retrying will never fix any of them. Stop, and alert a human.

Two behaviours worth knowing:

  • There are no RateLimit-* headers on a 401. The per-key limiter runs only after the key resolves. Your header-parsing code must tolerate their absence.
  • Audit asymmetry. Missing and unknown tokens are written to the application log only, never the database — otherwise an anonymous caller could turn every request into a database write. Expired and revoked/disabled tokens are recorded against that key's usage history, because they identify a real credential.

4. Scopes

A key carries four independent scope dimensions. Three of them are permissive by default. The fourth is not, and that asymmetry is the single most surprising rule in this API.

                      unset (NULL) means…
  scope_sites         ALL sites            ← permissive
  scope_departments   ALL departments      ← permissive
  scope_kpis          ALL KPIs             ← permissive
  scope_datasets      *** NO DATASETS ***  ← INVERTED, fail-closed

4.1 scope_sites — intersection that never widens

The requested sites filter is intersected with the key's allowlist. It can narrow what you get; it can never widen it.

Key's scope_sites Your sites= parameter Effective sites
null (all) (omitted) all sites
null (all) 3,4 3,4
[3,4] (omitted) 3,4
[3,4] 3,9 3
[3,4] 9entirely forbidden 3,4 ← falls back to the key's own set

That last row is the surprising one, and it is deliberate. An empty intersection must never be allowed to become the internal "no restriction" sentinel, because that sentinel is read downstream as all sites — which would hand you the whole organisation. Instead it falls back to the key's own allowlist. A request for a site you cannot see returns your own data with a 200, not an error and not somebody else's data.

Always compare the site_ids (or sites[]) echoed in the response against what you asked for. It is the only way to detect that your filter was not honoured verbatim.

Worked example. Key scoped to scope_sites: [3, 4]:

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/car_count/value?period=mtd&sites=3,9"
{
  "kpi_id": "car_count",
  "site_ids": [3],
  "value": 21874,
  "...": "..."
}

Site 9 was silently dropped. Had you asked for sites=9 alone you would have received "site_ids": [3, 4] — everything the key can see.

Parameter spelling. Both sites (canonical) and site_ids (deprecated alias) are accepted on every endpoint that takes a site filter. sites is read first and wins if both are present. Use sites in new code.

4.2 scope_departments and scope_kpis — a union, not an intersection

KPI access is scope_departments ∪ scope_kpis. If neither is set, the key reads every KPI.

The four departments are operations, maintenance, support, hr.

Worked example. A key with scope_departments: ["maintenance"] and scope_kpis: ["car_count"] can read every maintenance KPI plus car_count — not their (empty) intersection. This lets an admin grant a whole department and a few extras on one key.

curl -s -H "Authorization: Bearer $TOKEN" "$BASE/catalog"
# → every maintenance KPI, plus car_count. Nothing else.

/catalog silently omits KPIs outside KPI scope — there is no denied array there. Use it as your discovery surface for KPI identifiers and metadata, with one important limit:

/catalog reflects KPI scope only. It is not the authoritative answer to "what can this key read". It never applies the global-KPI rule from §4.3, so a site-restricted key is still listed the six scope: "global" KPIs — and then gets 403 from /kpis/{id}/value and /kpis/{id}/timeseries, or finds them in denied[] on /kpis/values and /kpis/bulk. Either filter entry["scope"] == "global" out yourself when you know the key is site-restricted, or tolerate the 403/denied outcome.

There is no scope-introspection endpoint. Nothing in this API tells a key its own scope_sites, scope_kpis, scope_datasets or rate-limit overrides, and /sites cannot distinguish "unrestricted key" from "key scoped to every site" — both return the same list. Get the key's scope from the administrator out of band; do not discover it by provoking 403s (each one still costs its endpoint budget).

4.3 Global-scope KPIs and site-restricted keys

Six KPIs are organisation-wide numbers with no site dimension:

calls   calls_answered   avg_call_time
call_location_state   call_location_city   weeks_above_4_5

A key with any scope_sites restriction is refused these KPIs, even when its KPI scope permits them:

{
  "error": "This KPI is organisation-wide and cannot be narrowed to the sites this API key is restricted to.",
  "code": "FORBIDDEN"
}

There is no correct narrowed answer to serve — the number covers the whole organisation, so returning it would leak org-wide data to a site-restricted key. Refusing is the documented behaviour. A key with scope_sites: null reads them normally.

⚠ Do not send sites= with an organisation-wide KPI

The 403 above only fires for a site-restricted key. An unrestricted key that passes sites= alongside a global KPI gets something worse: a silent zero.

/kpis/{id}/value and /kpis/values apply the filter verbatim — unlike /kpis/{id}/timeseries, which forces view=org and site_ids: null for these KPIs. Global rows live against the synthetic GLOBAL site, so any real site ID matches nothing:

curl -s "$BASE/kpis/calls/value?sites=2" -H "Authorization: Bearer $TOKEN"
# → { "value": 0, "value_formatted": "0", "site_ids": [2],
#     "freshness": { "state": "no_data", ... } }

curl -s "$BASE/kpis/calls/value" -H "Authorization: Bearer $TOKEN"
# → { "value": 2500, ... }

A client that passes its usual site list to every KPI therefore reports zero calls with a 200. Omit sites for these six KPIs, or request them in a separate call. The freshness.state: "no_data" is your only signal — one more reason to gate on it.

4.4 scope_datasets — the inverted default

⚠ Read this twice

scope_datasets inverts the rule of the other three. Unset means NO ACCESS, not "all".

  • A key with no scope restrictions at all — every site, every department, every KPI — still receives 403 FORBIDDEN from /demand/heatmap.
  • scope_departments: ["operations"] does not grant it, even though the operations department contains every KPI the heat-map exposes.
  • Every key minted before datasets existed has no dataset access and must be explicitly re-scoped by an admin.

Datasets must be granted by name. Today there is exactly one: demand_hourly, which gates GET /demand/heatmap.

Why it inverts: the hourly demand grid exposes per-hour throughput and each site's inferred opening and closing times — a materially finer disclosure than the daily KPI totals the rest of the API serves. A key must not acquire that silently, either by predating the feature or by happening to hold car_count.

Worked example. A completely unrestricted key:

curl -s -o /dev/null -w '%{http_code}\n' \
  -H "Authorization: Bearer $TOKEN" "$BASE/demand/heatmap"
# 403
{
  "error": "This API key is not scoped to read the 'demand_hourly' dataset. An administrator can grant it under Admin → API Keys.",
  "code": "FORBIDDEN"
}

The fix is an admin action: Admin → API Keys, edit the key, tick demand_hourly. There is nothing a client can do about it.

4.5 How a denial reaches you — three different shapes

Which shape you get depends on whether the endpoint returns one thing or many.

(a) Whole-endpoint refusal → 403 FORBIDDEN

Endpoint Condition Message
/kpis/{id}/value, /kpis/{id}/timeseries KPI outside scope This API key is not scoped to read that KPI.
/kpis/{id}/value, /kpis/{id}/timeseries global KPI + site-restricted key This KPI is organisation-wide and cannot be narrowed to the sites this API key is restricted to.
/demand/heatmap dataset not granted This API key is not scoped to read the 'demand_hourly' dataset. …
/demand/heatmap dataset granted, but zero cell fields permitted by KPI scope This API key is not scoped to read any demand metric.

(b) Partial refusal → 200 with denial arrays

/kpis/values and /kpis/bulk never return 403 or 404. They split your requested kpi_ids three ways and return 200:

{
  "values": [ "…served KPIs…" ],
  "denied":  ["revenue"],
  "unknown": ["not_a_kpi"],
  "meta": { "count": 1, "…": "…" }
}
Array Meaning Who fixes it
denied A real KPI, outside this key's scope — or a global KPI on a site-restricted key An admin must widen the key
unknown Not a KPI ID at all You must fix the request

These are distinct facts and both arrive with a 200. A client that ignores them will silently report zeros for KPIs it never received. Log both.

/demand/heatmap uses a third array, denied_fields[] — see §7.4.

(c) Silent filtering — no signal at all

/catalog omits out-of-scope KPIs and /sites omits out-of-scope sites, with no accompanying array. Use both for discovery of identifiers rather than hard-coding them — but note the limit in §4.2: /catalog filters on KPI scope only, so a site-restricted key is still listed the six global KPIs it will be refused, and neither endpoint reveals whether the key is site-restricted at all.


5. Rate limits

Two layers, with different jobs. Only the second is authoritative.

5.1 Layer 1 — the pre-auth flood guard

Property Value
Allowance 600 requests / 60 s
Keyed on client IP (port-stripped; IPv6 brackets handled)
Scope one bucket for the whole API, not per route
Algorithm sliding window, in-process, per server worker, lost on worker recycle
Cost weighting none — every request counts 1
Runs before authentication, so it can shed anonymous floods

Its only job is to make an unauthenticated flood cheap to discard before any database work happens. Do not pace against it. On trip it returns 429 RATE_LIMITED with Retry-After, RateLimit-Limit: 600, RateLimit-Remaining: 0 and RateLimit-Reset — but no RateLimit-Policy. That absence is the cheapest way to tell a flood-guard 429 from a per-key one.

5.2 Layer 2 — the authoritative per-key limit

Property Value
Default per minute 120
Default per day 50,000
Overrides per key, set by an admin, 1 – 1,000,000 on either
Buckets one 60-second and one 86,400-second bucket, both charged on every request that is not already throttled — the minute bucket is evaluated first, and a minute-throttled request never touches the daily budget
Storage database-backed, so it is shared across all server workers and survives worker recycles
Isolation strictly per key — throttling one key never affects another
Evaluation point after the key resolves, before any scope or parameter validation

5.3 Endpoint cost weighting

Not every request costs the server the same. A /health call is a handful of rows; a /kpis/bulk call can be a quarter of a million. Cost is charged against the same budget, so there is still only one number to publish.

Endpoint Cost Calls per minute at the default 120
GET /kpis/bulk 10 12
GET /demand/heatmap 10 12
GET /kpis/{id}/timeseries 5 24
GET /kpis/values 3 40
GET /kpis/{id}/value 1 120
GET /catalog 1 120
GET /sites 1 120
GET /health 1 120
GET /openapi.json 1 120

Three consequences integrators get wrong:

  1. RateLimit-Remaining is in cost units, not requests. Remaining: 20 on a 120/min key means two more bulk calls, not twenty. Before issuing a cost-10 call, require Remaining >= 10.
  2. The charge is applied before the handler runs and is never refunded. A request that ends in 400, 403, 404, 429, 500 or 503 BUSY has still spent its full cost. A retried 503 BUSY on /kpis/bulk costs another 10. A 401 is the only status that costs nothing, because the limiter runs after the key resolves — plus two non-statuses: a 405 (routing fails before the API's hooks bind) and a flood-guard 429 (it runs before authentication). Retrying into a per-key 429 still charges, so obey Retry-After rather than polling.
  3. The minute bucket is evaluated first. If it trips, the day bucket is never charged — minute-throttled traffic does not burn daily budget. Also note HEAD is charged exactly like GET (see §1); there is no cheap-probe method.

5.4 Response headers

Set on every response where a key resolved and the limiter ran — 2xx, scope denials, 5xx, and the per-key 429 alike.

Header Meaning
RateLimit-Limit The limit of the most constraining bucket
RateLimit-Remaining max(0, limit − used) for that bucket, in cost units
RateLimit-Reset Seconds remaining until that bucket resets. A relative delta — not a Unix epoch
RateLimit-Policy "<limit>;w=<window_seconds>;cost=<cost_of_this_request>", e.g. 120;w=60;cost=10
Retry-After 429 and 503. On 429, max(1, seconds until reset); on 503 it is a fixed 5 (BUSY) or 10 (DB_UNAVAILABLE)

The headers describe the bucket with the fewest cost units left — measured in absolute terms, not as a fraction consumed. Read RateLimit-Policy's w= to know which window you are being told about.

⚠ The daily budget is invisible until you are nearly out of it

Because the bucket is chosen by absolute remaining, and minute-remaining can never exceed rate_limit_per_min (120 by default) while daily-remaining starts at 50,000, the daily bucket only becomes "most constraining" in the final ~120 cost units of the day. In practice you see RateLimit-Limit: 120 all day, then it flips to 50000 when you are 120 units from the wall, then you are throttled. There is no gradual, observable transition to react to.

The headers cannot be used to pace against the daily budget. Track your own daily spend locally — sum ENDPOINT_COST for every call you make since 00:00 UTC — and stop at a self-imposed threshold (say 80% of 50,000). Also note that when the daily bucket is the reported one, RateLimit-Reset is the time until midnight UTC, up to 86,400 seconds; never sleep that verbatim.

Fail-open caveat. If the limiter's store is unreachable, the request is allowed and no RateLimit-* headers appear at all. Never fail a read because the headers are missing — treat their absence as "budget unknown" and fall back to your own pacing.

5.5 The fixed-window caveat — read before writing a pacer

The counter is a fixed window, not a sliding one. Windows are aligned to the Unix epoch:

  • the minute bucket resets at each wall-clock :00 second (UTC);
  • the daily bucket resets at 00:00:00 UTC — not on a rolling 24 hours, and not in your local timezone.

Stated honestly: a client can spend up to 2× the limit across a boundary. 120 requests at 10:00:59 plus another 120 at 10:01:00 is 240 requests in two seconds, and every one of them succeeds. This is an implementation consequence of a safety backstop, not a billing meter, and it is documented rather than papered over.

What that means for you:

  • Do not design a burst pattern that relies on it. It is slack, not a guarantee, and it can be tightened.
  • Do expect the inverse pain. After a boundary burst you will hit the wall much sooner in the following window than a sliding-window mental model predicts.
  • Pace off RateLimit-Reset, which tells you exactly how long the current window has left. Guessing from Retry-After alone is not enough.
CONFIG
  base         = 1s
  cap          = 60s      # never sleep longer than this per RETRY attempt
  pace_cap     = 300s     # never sleep longer than this when pacing proactively
  max_attempts = 5        # per logical request
  floor        = 0.20     # pause proactively below 20% of the bucket

STATE (per API key, SHARED across all your workers)
  cooldown_until = 0
  last_remaining = None   # cost units left, from the most recent response
  last_reset     = None   # seconds until that bucket rolls

BEFORE EACH REQUEST
  # Remaining is in COST units: a cost-10 call needs 10 units, not 1.
  if last_remaining is not None and last_remaining < cost_of(next_request):
      sleep(min(last_reset, pace_cap))
  if now < cooldown_until: sleep(cooldown_until - now)

AFTER EACH RESPONSE

  # 1. Proactive pacing — the whole point of the headers. Do this on EVERY
  #    response, including 200s. Waiting for a 429 means you were already
  #    throttled.
  if RateLimit-Remaining and RateLimit-Limit present:
      last_remaining = int(RateLimit-Remaining)
      last_reset     = int(RateLimit-Reset)               # relative seconds
      if last_remaining <= floor * int(RateLimit-Limit):
          # CAP IT. On the daily bucket Reset is seconds until 00:00 UTC —
          # up to 86,400. Sleeping that verbatim looks like a hung process.
          cooldown_until = now + min(last_reset, pace_cap)
      # If RateLimit-Policy shows w=86400 you are DAILY-constrained: stop the
      # run and alert. Do not sit in a loop until midnight UTC.

  # 2. Terminal — never retry
  if status == 401: alert_human(); abort      # bad / expired / revoked key
  if status == 403: fix_scope();   abort      # an admin must widen the key
  if status == 400: fix_request(); abort      # deterministic, will not heal
  if status == 404: abort                     # unknown KPI

  # 3. Throttled — obey the server, then back off
  if status == 429:
      wait = Retry-After, else RateLimit-Reset, else base * 2^attempt
      wait = min(wait, cap)
      sleep(wait + jitter(0, wait * 0.3))
      cooldown_until = now + wait             # pause SIBLING workers too
      retry

  # 4. Server shed / transient
  if status == 503:                           # BUSY sends 5, DB sends 10
      wait = Retry-After, else base * 2^attempt
      sleep(min(wait, cap) + jitter(0, wait * 0.5))   # MORE jitter: BUSY is
      retry                                           # contention, so
                                                      # synchronised retries
                                                      # re-collide
  if status in (500, 502, 504):
      sleep(min(base * 2^attempt, cap) + jitter); retry

  # 5. Success — but check the body
  if status == 200:
      if body.denied non-empty:        log — scope gap, a human must fix it
      if body.unknown non-empty:       log — your request is wrong
      if body.denied_fields non-empty: log — heat-map fields withheld
      if body.freshness.state != 'fresh': see §8

Specifics that matter here:

  • Honour Retry-After over your own schedule. It is computed from the real window boundary, so it is exact.
  • Jitter is mandatory. Both the fixed window and the single heavy-read slot synchronise clients — everyone throttled in the same minute is released at the same instant, and everyone shed by 503 BUSY retries into the same contended slot. Undithered retries re-collide deterministically.
  • Share cooldown state across your workers. The budget is per key, not per process. Ten workers sharing one token must share one cooldown.
  • Cap concurrency at 1 for heavy endpoints. /kpis/bulk, /kpis/{id}/timeseries and /demand/heatmap contend for a single slot per server worker. Issuing them in parallel from one client mostly produces 503 BUSY, and every shed request still costs its full 10, 5 or 10.
  • Prefer the largest request that stays under 50,000 projected cells — see the caps in §10 and the partition recipe in §10.1. Fewer, larger calls are cheaper per row, but "one 366-day call for everything" does not fit at portfolio scale: 15 sites × 366 days caps you at 9 KPIs per call, and a tenth KPI is a deterministic 400 that has already cost you 10 units.
  • Cap any pacing sleep. RateLimit-Reset on the daily bucket is seconds until midnight UTC. A pacer that sleeps it verbatim hangs for up to a day.
  • Never retry a 400 for an oversized projection. It is deterministic. Narrow the range, the sites or the kpi_ids and reissue.

A runnable implementation is in §11.


6. Endpoint reference

Nine endpoints, all GET.

Path Cost Heavy slot Takes sites? Purpose
/catalog 1 no no KPI definitions
/sites 1 no no — any query string is ignored Sites + connector states
/kpis/{kpi_id}/value 1 no yes One aggregated value
/kpis/values 3 no yes Batch of aggregated values
/kpis/{kpi_id}/timeseries 5 yes yes Raw daily series
/kpis/bulk 10 yes yes Wide multi-KPI × multi-site pull
/demand/heatmap 10 yes yes Hourly demand grid (§7)
/health 1 no no Connector freshness summary
/openapi.json 1 no no The OpenAPI document

Five endpoints take a site filter, not six — count the column.

"Heavy slot" means the endpoint takes a process-wide single-occupancy lock; a concurrent second heavy read is shed with 503 BUSY (see §9.2).

Set up for the examples below:

TOKEN="nxk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
BASE="https://rapid-elt.azurewebsites.net/api/external/v1"

6.0 Parameters shared across endpoints

sites / site_ids — comma-separated integers, on the five data endpoints that take a site filter: /kpis/{id}/value, /kpis/values, /kpis/{id}/timeseries, /kpis/bulk and /demand/heatmap. /catalog, /sites, /health and /openapi.json never read it. Parsing is strict:

Input Result
sites=3,4 filter to sites 3 and 4 (then intersected with the key's scope)
omitted, or empty string the key's allowlist, or null for an unrestricted key
sites=1,abc,2 400sites must be a comma-separated list of integer site IDs.
sites=0 or a value above 2⁶³−1 400sites contains an invalid site ID.
more than 100 IDs 400Too many site IDs (max 100).
sites=,,, parses to an empty list, which downstream means no filter while the response echoes site_ids: []. Avoid.
any real site ID, on one of the six organisation-wide KPIs, at /kpis/{id}/value or /kpis/values 200 with a silent value: 0 — global rows live against the synthetic GLOBAL site. See §4.3. Omit sites for those KPIs.

period — accepted on /kpis/{id}/value and /kpis/values only. Default mtd. Explicit start_date + end_date take precedence and suppress it, in which case the response echoes requested_period: null.

An unknown keyword is a 400 (it used to fall through to a silent MTD):

{ "error": "Unknown period 'last7'. Valid values: all_time, last_30_days, …", "code": "BAD_REQUEST" }

Use these seven keywords only. They are the ones that resolve to the window their name implies:

Keyword Resolves to
today today … today
yesterday yesterday … yesterday
mtd 1st of this month … today
ytd 1 January … today
last_week previous Monday … previous Sunday
last_month 1st … last day of the previous month
last_year 1 Jan … 31 Dec of last year

Seven further keywords are accepted by validation but currently resolve to MTD, silently: wtd, qtd, last_quarter, last_7_days, last_30_days, last_90_days, all_time. This is a known defect. Do not use them. If you need those windows, compute the dates yourself and pass explicit start_date / end_date — which is the more robust pattern regardless, because period keywords resolve against the server process's timezone (UTC on the production host, and not client-configurable). See §2.1.

Explicit date ranges are all-or-nothing wherever they appear:

Condition Response
Only one of the pair supplied 400start_date and end_date must both be provided as real YYYY-MM-DD dates.
Malformed, or calendar-impossible (2026-02-31, 2026-13-01) 400, same message
start_date > end_date 400start_date must be on or before end_date.
Inclusive span above the cap 400Date range exceeds 366 days. (or 120 on the heat-map)

6.1 GET /catalog — KPI definitions

Returns the KPI dictionary with no values, filtered to the key's KPI scope and sorted by (department, kpi_id). This is your discovery surface: never hard-code KPI IDs.

Parameter Type Required Default Notes
department string no (all) operations, maintenance, support, hr. Not validated — an unknown value is not a 400; it matches nothing and returns an empty list.
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/catalog?department=operations"
{
  "kpis": [
    {
      "kpi_id": "awp",
      "label": "Blended AWP",
      "unit": "currency",
      "category": "Operations",
      "department": "operations",
      "positive_direction": "up",
      "aggregation_method": "weighted_avg",
      "decimal_places": 2,
      "scope": "site",
      "kpi_type": "calculated",
      "description": "Blended Average Wash Price — total revenue divided by total car count.",
      "formula": {
        "type": "ratio",
        "kpi_id": "awp",
        "kpi_label": "Blended AWP",
        "unit": "currency",
        "numerator":   { "id": "revenue",   "label": "Revenue",   "unit": "currency", "color_hex": "#f59e0b" },
        "denominator": { "id": "car_count", "label": "Car Count", "unit": "count",    "color_hex": "#f59e0b", "offset_months": null },
        "multiplier": null
      },
      "source": null
    },
    {
      "kpi_id": "car_count",
      "label": "Car Count",
      "unit": "count",
      "category": "Operations",
      "department": "operations",
      "positive_direction": "up",
      "aggregation_method": "sum",
      "decimal_places": null,
      "scope": "site",
      "kpi_type": "source",
      "description": "Total vehicles washed.",
      "formula": null,
      "source": "Rinsed"
    }
  ],
  "meta": { "count": 2, "generated_at": "2026-08-10T17:52:46Z" }
}
Field Type Meaning
kpi_id string the identifier used by every other endpoint
unit enum count, currency, percentage, hours, days, seconds, gallons, weeks, rating. Drives value_formatted
category string raw label: Operations, Maintenance, Support, Human Resources
department string normalized slug used by scope_departments and the department filter
positive_direction up | down which direction is favourable. Use it for delta colouring — it is semantic, not directional
aggregation_method enum sum, average, weighted_avg, latest. How a range collapses to one number
decimal_places int | null Set on exactly five KPIs: 2 on the four currency ratios (awp, retail_awp, member_awp, labor_cpc), 1 on labor_pct_sales, and null on the other fifty — including the sixth formula KPI, churn_rate. It is a hint for your formatting; it does not describe value_formatted, which is generated without it
formula.multiplier number | null Load-bearing, not decorative. 100 on labor_pct_sales and churn_rate, null on the four currency ratios. Some endpoints apply it and some do not — see §6.5 and §6.6. You need this value to interpret daily formula rows
scope site | global global KPIs have no site dimension — see §4.3
kpi_type source | calculated calculated iff formula is non-null
formula object | null non-null only for the six ratio KPIs (awp, retail_awp, member_awp, labor_cpc, labor_pct_sales, churn_rate)
source string | null vendor label (Rinsed, MaintainX, Firebase, Sonny's Heatwave, Birdeye, Microsoft Graph). null for every calculated KPI

Across the full catalogue there are 55 KPIs: 49 site-scope and 6 global; 33 sum, 13 average, 6 weighted_avg, 3 latest. The six weighted_avg KPIs are exactly the six formula KPIs.

Errors: 401, 429.


6.2 GET /sites — sites and connector states

curl -s -H "Authorization: Bearer $TOKEN" "$BASE/sites"

Takes no parameters. Any query string is ignored, including sites — site visibility comes from the key's scope alone.

{
  "sites": [
    { "id": 3, "name": "WashU Midtown", "organization": "WashU" },
    { "id": 4, "name": "WashU Delmar",  "organization": "WashU" }
  ],
  "sources": [
    {
      "connector_id": "rinsed",
      "name": "Rinsed",
      "status": "idle",
      "last_success_at": "2026-08-10T05:14:22.481903",
      "last_error": null,
      "is_stale": false
    },
    {
      "connector_id": "firebase_selfserve",
      "name": "Firebase Self-Serve (retired)",
      "status": "disabled",
      "last_success_at": "2025-11-02",
      "last_error": null,
      "is_stale": true
    }
  ],
  "meta": { "site_count": 2, "generated_at": "2026-08-10T17:52:46Z" }
}
Field Notes
sites[].id the integer used by sites= everywhere else
sites[].organization may be null
sources[].status idle, running, error, disabled
sources[].last_success_at raw naive-UTC value — see §8.5
sources[].last_error present here; absent from /health

The synthetic GLOBAL pseudo-site that backs global KPIs is never listed.

/sites and /health disagree on purpose. /sites lists every connector state, including decommissioned ones, and computes is_stale without any retirement exemption — so a retired connector shows is_stale: true here. /health skips retired connectors entirely, and the freshness blocks treat them as retired rather than stale. If you want an alertable signal, use /health. Use /sites.sources[] for an inventory, not an alarm.

Errors: 401, 429.


6.3 GET /kpis/{kpi_id}/value — one aggregated value

# Parameter Type Required Default
1 period enum no mtd — ignored when explicit dates are given
2 start_date YYYY-MM-DD no*
3 end_date YYYY-MM-DD no*
4 sites / site_ids CSV ints no the key's allowlist

* all-or-nothing: supplying either requires both.

sites is applied verbatim here, including to organisation-wide KPIs. Unlike /timeseries, this endpoint performs no global-scope coercion, and global rows live against the synthetic GLOBAL site — so sites=2 on calls returns value: 0 with freshness.state: "no_data" and site_ids: [2] echoed back. Omit sites for the six global KPIs (§4.3).

Scope gates run before parameter validation: unknown KPI (404) → KPI scope (403) → global-vs-site-restricted (403) → then the 400s.

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/revenue/value?start_date=2026-07-01&end_date=2026-07-31&sites=3,4"
{
  "kpi_id": "revenue",
  "label": "Revenue",
  "unit": "currency",
  "period": { "start": "2026-07-01", "end": "2026-07-31", "requested_period": null },
  "site_ids": [3, 4],
  "value": 1234567.5,
  "value_formatted": "$1.23M",
  "aggregation_method": "sum",
  "freshness": {
    "source": "rinsed",
    "source_name": "Rinsed",
    "last_synced_at": "2026-08-10T05:14:22.481903",
    "state": "fresh",
    "is_stale": false,
    "sources": [
      {
        "kpi_id": "revenue",
        "source": "rinsed",
        "source_name": "Rinsed",
        "last_synced_at": "2026-08-10T05:14:22.481903",
        "state": "fresh",
        "retired": false,
        "is_stale": false
      }
    ],
    "retired_sources": [],
    "components": null
  },
  "generated_at": "2026-08-10T17:52:46Z"
}
Field Meaning
period.start / .end the resolved inclusive bounds actually used
period.requested_period the echoed keyword, or null when explicit dates were supplied
site_ids the effective filter after intersection with the key's scope; null means all sites
value never null — a missing aggregate becomes 0. This is exactly why you must read freshness. Count-unit KPIs are integers; formula KPIs are rounded to 4 decimal places
value_formatted server-side compact formatting: "$1.23M", "721", "12.3%", "4.1 hrs". Prefer it over reinventing formatting
aggregation_method how the range was collapsed

A formula KPI returns the identical envelope; only its freshness differs (see §8.4).

Errors: 400, 401, 403, 404 (Unknown KPI.), 429, 500. This endpoint does not take the heavy slot, so it never returns 503 BUSY.


6.4 GET /kpis/values — batch of aggregated values

# Parameter Type Required Default
1 kpi_ids CSV strings, ≤ 50 yes
2 period enum no mtd
3 start_date / end_date YYYY-MM-DD no
4 sites / site_ids CSV ints no key's allowlist
curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/values?kpi_ids=car_count,revenue,not_a_kpi&period=last_month&sites=3,4"
{
  "values": [
    {
      "kpi_id": "car_count",
      "label": "Car Count",
      "unit": "count",
      "period": { "start": "2026-07-01", "end": "2026-07-31", "requested_period": "last_month" },
      "site_ids": [3, 4],
      "value": 128740,
      "value_formatted": "128.7K",
      "aggregation_method": "sum",
      "freshness": { "source": "rinsed", "source_name": "Rinsed", "last_synced_at": "2026-08-10T05:14:22.481903", "state": "fresh", "is_stale": false, "sources": [ "…" ], "retired_sources": [], "components": null },
      "generated_at": "2026-08-10T17:52:46Z"
    },
    { "kpi_id": "revenue", "…": "…" }
  ],
  "denied": [],
  "unknown": ["not_a_kpi"],
  "meta": {
    "count": 2,
    "period": { "start": "2026-07-01", "end": "2026-07-31", "requested_period": "last_month" },
    "generated_at": "2026-08-10T17:52:46Z"
  }
}

Each entry of values[] is exactly the /kpis/{id}/value envelope, including its own period, site_ids, freshness and generated_at, in the order you submitted. meta.count is len(values) — the number served, not the number requested.

This endpoint never returns 403 or 404. Out-of-scope KPIs land in denied; nonexistent ones land in unknown. Both come back with a 200.

Errors: 400 (missing kpi_ids, > 50, bad period, bad dates, bad sites), 401, 429, 500.

Asymmetry worth knowing. /kpis/values has no projected-size cap, so a 50-KPI × all-sites × 366-day request is accepted here at cost 3, while a single-KPI /kpis/bulk costs 10 and is size-capped. If you need aggregates rather than daily rows, this is by far the cheaper endpoint.


6.5 GET /kpis/{kpi_id}/timeseries — raw daily series

Cost 5. Takes the heavy slot — can return 503 BUSY.

# Parameter Type Required Default Notes
1 start_date YYYY-MM-DD yes period is not accepted here
2 end_date YYYY-MM-DD yes span ≤ 366 days
3 view enum no site site or org, case-insensitive. Anything else → 400 view must be 'site' or 'org'.
4 sites / site_ids CSV ints no key's allowlist ≤ 100

The projected result size (1 × sites × days) is checked before any row is read; over 50,000 points is a 400.

view=site (default)

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/car_count/timeseries?start_date=2026-07-01&end_date=2026-07-07&sites=3"
{
  "kpi_id": "car_count",
  "label": "Car Count",
  "unit": "count",
  "view": "site",
  "range": { "start": "2026-07-01", "end": "2026-07-07" },
  "site_ids": [3],
  "points": [
    { "date": "2026-07-01", "site_id": 3, "site": "WashU Midtown", "value": 812, "source": "rinsed" },
    { "date": "2026-07-02", "site_id": 3, "site": "WashU Midtown", "value": 774, "source": "rinsed" }
  ],
  "freshness": { "source": "rinsed", "source_name": "Rinsed", "last_synced_at": "2026-08-10T05:14:22.481903", "state": "fresh", "is_stale": false, "sources": [ "…" ], "retired_sources": [], "components": null },
  "meta": { "point_count": 7, "generated_at": "2026-08-10T17:52:53Z" }
}

view=org

Identical envelope, but points carry only two fields — there is no per-site breakdown and no source:

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/car_count/timeseries?start_date=2026-07-01&end_date=2026-07-07&view=org"
{
  "kpi_id": "car_count",
  "label": "Car Count",
  "unit": "count",
  "view": "org",
  "range": { "start": "2026-07-01", "end": "2026-07-07" },
  "site_ids": null,
  "points": [
    { "date": "2026-07-01", "value": 3288 },
    { "date": "2026-07-02", "value": 3105 }
  ],
  "freshness": { "…": "…" },
  "meta": { "point_count": 7, "generated_at": "2026-08-10T17:52:53Z" }
}

Point shapes, all four combinations

view KPI kind Point fields source Formula scale Missing/zero denominator
site stored date, site_id, site, value, source the real connector ID, e.g. "rinsed" n/a n/a
site formula date, site_id, site, value, source literal "calculated"; site_id may be null if the site name did not resolve multiplier NOT applied — raw fraction the (date, site) point is omitted entirely — sparse series, absence ≠ zero
org stored date, value (absent) n/a n/a
org formula date, value (absent) — computed as SUM(numerator)/SUM(denominator) per day multiplier applied — percentage a literal {"value": 0} is emitted, which is not a real ratio

labor_pct_sales and churn_rate come back on two different scales

Both carry formula.multiplier: 100. view=org applies it; view=site does not. /kpis/{id}/value applies it. /kpis/bulk does not. All four responses report unit: "percentage", and nothing in the payload tells you which scale you are holding — a partner summing daily labor_pct_sales from view=site and reconciling against the monthly /value is wrong by two orders of magnitude with no signal.

When reading daily formula rows, multiply by formula.multiplier from /catalog. The four currency ratios (awp, retail_awp, member_awp, labor_cpc) have no multiplier and are unaffected. This is a known defect, documented rather than papered over; when it is fixed the view=site / /kpis/bulk values will change scale, so key the multiplication off /catalog rather than hard-coding 100.

Missing denominators: site and org behave in opposite ways. view=site drops the day; view=org fabricates a 0 for it. So point_count below your span is expected on view=site and means missing data, while a 0 on view=org is not evidence that the ratio was zero. Cross-check the component KPIs before trusting either.

churn_rate is not comparable across endpoints. With denominator_offset_months: -1, view=org accumulates the numerator against a single fixed denominator (a cumulative series, not a daily one), view=site divides by a per-site average of the offset window, and /kpis/{id}/value averages per-month rates. Three definitions of the same KPI. Do not reconcile churn across endpoints — pick one and stay on it.

Notes and caveats:

  • range has no requested_period, unlike /value's period.
  • Global-scope KPIs force view=org after validation. view=site on calls is accepted, returns org points, and the response echoes "view": "org". site_ids is forced to null for these, because the filter was not applied and echoing it would misrepresent what the numbers cover.
  • org rollups sum unless the KPI's aggregation_method is average. The three latest KPIs — active_members, wo_active, headcount — are therefore summed across sites in view=org. Summing a stock measure across sites is usually what you want for a portfolio total, but be deliberate about it.
  • Site-view points are ordered (site_id, date); org points by date.

Errors: 400, 401, 403, 404, 429, 503 BUSY, 500.


6.6 GET /kpis/bulk — wide multi-KPI × multi-site pull

Cost 10. Takes the heavy slot — can return 503 BUSY. This is the efficient way to pull several sites × several KPIs across many dates in one request.

# Parameter Type Required Default Notes
1 kpi_ids CSV strings, ≤ 50 yes
2 start_date YYYY-MM-DD yes no period support
3 end_date YYYY-MM-DD yes span ≤ 366 days
4 shape enum no records records or matrix, case-insensitive
5 sites / site_ids CSV ints no key's allowlist ≤ 100. Both spellings work here

Projected size is allowed_kpis × sites × days and must stay under 50,000. Denied and unknown IDs are excluded from that count.

What "sites" means in that projection. It is len(site_ids) after intersection with the key's allowlist — and when you omit sites entirely, it falls back to the server's total active-site count, a number your client cannot see. So the naive client-side precheck len(kpis) * len(my_sites) * days is wrong whenever sites is omitted. Send sites explicitly if you want to predict the cap. See §10.1 for a partition recipe.

shape=records (default)

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/bulk?kpi_ids=car_count,awp&sites=3,4&start_date=2026-07-01&end_date=2026-07-31"
{
  "shape": "records",
  "range": { "start": "2026-07-01", "end": "2026-07-31" },
  "site_ids": [3, 4],
  "records": [
    { "kpi_id": "car_count", "site_id": 3, "site": "WashU Midtown", "date": "2026-07-01", "value": 812,    "source": "rinsed" },
    { "kpi_id": "car_count", "site_id": 3, "site": "WashU Midtown", "date": "2026-07-02", "value": 774,    "source": "rinsed" },
    { "kpi_id": "awp",       "site_id": 3, "site": "WashU Midtown", "date": "2026-07-01", "value": 14.8732, "source": "calculated" }
  ],
  "freshness": {
    "car_count": { "source": "rinsed", "source_name": "Rinsed", "last_synced_at": "2026-08-10T05:14:22.481903", "state": "fresh", "is_stale": false, "sources": [ "…" ], "retired_sources": [], "components": null },
    "awp":       { "source": null,     "source_name": "multiple", "last_synced_at": "2026-08-09T05:11:07.998214", "state": "fresh", "is_stale": false, "sources": [ "…" ], "retired_sources": [], "components": [ "…" ] }
  },
  "denied": [],
  "unknown": [],
  "meta": {
    "record_count": 124,
    "kpi_count": 2,
    "site_count": 2,
    "generated_at": "2026-08-10T17:53:02Z"
  }
}

shape=matrix

Identical, except records is replaced by matrix, nested {kpi_id: {site_id: {date: value}}}:

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/kpis/bulk?kpi_ids=car_count&sites=3,4&start_date=2026-07-01&end_date=2026-07-03&shape=matrix"
{
  "shape": "matrix",
  "range": { "start": "2026-07-01", "end": "2026-07-03" },
  "site_ids": [3, 4],
  "matrix": {
    "car_count": {
      "3": { "2026-07-01": 812, "2026-07-02": 774, "2026-07-03": 901 },
      "4": { "2026-07-01": 553, "2026-07-02": 610, "2026-07-03": 588 }
    }
  },
  "freshness": { "car_count": { "…": "…" } },
  "denied": [], "unknown": [],
  "meta": { "record_count": 6, "kpi_count": 1, "site_count": 2, "generated_at": "2026-08-10T17:53:02Z" }
}

Matrix mode caveats:

  • Site IDs become JSON object keys, so they are strings ("3", not 3).
  • Provenance and the site name are lost. There is no source and no site in matrix mode. If you need to know which connector produced a value, use records.
  • meta.record_count still counts the flat records, and any record whose site_id is null (a formula record whose site name did not resolve) is dropped from the matrix. So the number of values in matrix can be lower than record_count. Do not use record_count to validate matrix completeness.

Other notes:

  • freshness here is an object keyed by kpi_id, one full block per allowed KPI. Denied and unknown IDs are absent from it.
  • meta.site_count is null when no site filter applied.
  • Records are grouped stored KPIs first, then formula KPIs, not globally sorted. Do not rely on ordering; sort client-side if you need to.
  • Formula records carry source: "calculated"; consult that KPI's freshness[kpi].components for the underlying connectors.
  • Formula values here are unscaled. /kpis/bulk does not apply formula.multiplier, while /kpis/{id}/value and timeseries?view=org do. labor_pct_sales and churn_rate therefore arrive as raw fractions labelled unit: "percentage" — 100× smaller than the same KPI from /value. Multiply by formula.multiplier from /catalog before reconciling. Same known defect as in §6.5.
  • Formula records with a missing or zero denominator are omitted entirely, with no null marker — so a formula KPI legitimately returns fewer records than sites × days, and absence is not zero. (timeseries?view=org does the opposite and emits a literal 0.)
  • churn_rate is computed a different way on each of /value, timeseries?view=org and here. Do not reconcile it across endpoints.

Errors: 400, 401, 429, 503 BUSY, 500. Like /kpis/values, it never returns 403 or 404 — those fold into denied / unknown.


6.7 GET /demand/heatmap

See §7. It requires the demand_hourly dataset grant, which no key has by default.


6.8 GET /health — connector freshness summary

curl -s -H "Authorization: Bearer $TOKEN" "$BASE/health"

Takes no parameters.

{
  "status": "degraded",
  "server_time": "2026-08-10T17:53:09Z",
  "stale_sources": ["maintainx"],
  "sources": [
    {
      "connector_id": "rinsed",
      "name": "Rinsed",
      "status": "idle",
      "last_success_at": "2026-08-10T05:14:22.481903",
      "is_stale": false
    },
    {
      "connector_id": "maintainx",
      "name": "MaintainX",
      "status": "error",
      "last_success_at": "2026-08-06T05:02:19.310884",
      "is_stale": true
    }
  ]
}
Field Meaning
status "ok" when stale_sources is empty, "degraded" when it is not. This is the right thing to alert on.
stale_sources connector IDs currently judged stale
sources[] {connector_id, name, status, last_success_at, is_stale}five fields, no last_error (unlike /sites)

Retired connectors are skipped entirely, so a decommissioned predecessor never drags status to degraded.

A fully-degraded system still returns HTTP 200. status is data, not an HTTP result. This is not the same as the app's unauthenticated /healthz liveness probe, which lives outside this API.

Errors: 401, 429.


6.9 GET /openapi.json — the spec

curl -s -H "Authorization: Bearer $TOKEN" "$BASE/openapi.json"

Returns the committed OpenAPI 3.1 document as JSON. It requires a key like every other route. If the file is missing or unparseable you get 404 NOT_FOUND with "OpenAPI spec is not available."

Errors: 401, 404, 429.


7. The demand heat-map

GET /demand/heatmapcost 10, takes the heavy slot.

An hourly demand grid per site: a weekday pattern averaged across the requested range, plus a calendar of raw values for the most recent 28 days. It is a staffing instrument. Treat it accordingly — the sections on null and on the two analytical rules below are not pedantry, they are the difference between a correct schedule and a confidently wrong one.

7.1 Access: the demand_hourly dataset grant

This endpoint returns 403 to a key with no scope restrictions whatsoever. scope_datasets is fail-closed: unset means no datasets, not all datasets. See §4.4.

Two checks run before anything else, in this order:

  1. Dataset grant. Without demand_hourly on the key:

json { "error": "This API key is not scoped to read the 'demand_hourly' dataset. An administrator can grant it under Admin → API Keys.", "code": "FORBIDDEN" }

This is checked before the date range, so a key without the grant gets a 403 even when its dates are also invalid.

  1. At least one readable cell field. If the key's KPI scope excludes every one of the six mapped KPIs:

json { "error": "This API key is not scoped to read any demand metric.", "code": "FORBIDDEN" }

7.2 Parameters

Parameter Type Required Default
start_date YYYY-MM-DD no* today (UTC) − 12 weeks
end_date YYYY-MM-DD no* today (UTC)
sites / site_ids CSV ints no the key's allowlist

* all-or-nothing. Supplying only one is a 400, not a silently substituted default — a client's omission would otherwise become an undetectably wrong window.

  • Maximum span is 120 days, not the 366 that applies elsewhere. Hourly rows are roughly 70× denser per site-day than daily rows, so the daily cap would authorise a ~384,000-row read. Over 120 days: 400 Date range exceeds 120 days.
  • No period keyword. Sending period=mtd is silently ignored and you get the default window.
  • No projected-size cap. The 120-day span and the 100-site limit are the only volume controls.
  • No matching sites → 200 with an empty grid, not all sites. (But a site-restricted key asking only for sites outside its allowlist gets its own full allowlist back — see §4.1.)

7.3 Response

curl -s -H "Authorization: Bearer $TOKEN" \
  "$BASE/demand/heatmap?start_date=2026-06-01&end_date=2026-07-31&sites=3"
{
  "sites": [
    { "id": 3, "name": "WashU Midtown", "open_hour": 7, "close_hour": 19 }
  ],
  "hours": [7,8,9,10,11,12,13,14,15,16,17,18,19,20],
  "weekdays": ["Monday","Tuesday","Wednesday","Thursday","Friday","Saturday","Sunday"],
  "range": {
    "start": "2026-06-01",
    "end": "2026-07-31",
    "calendar_start": "2026-07-04",
    "calendar_end": "2026-07-31"
  },
  "pattern": {
    "3": {
      "Monday": [
        { "hour": 9,  "n": 9, "p90": 41,  "cars": 33, "retailCars": 12, "memberCars": 21, "eligible": 12, "sales": 1, "conversionRate": 8.9 },
        { "hour": 12, "n": 9, "p90": 118, "cars": 96, "retailCars": 40, "memberCars": 56, "eligible": 40, "sales": 5, "conversionRate": 11.7 }
      ],
      "Tuesday": [],
      "Wednesday": [], "Thursday": [], "Friday": [], "Saturday": [], "Sunday": []
    }
  },
  "calendar": {
    "3": {
      "2026-07-31": [
        { "hour": 9,  "cars": 38, "retailCars": 15, "memberCars": 23, "eligible": 15, "sales": 2, "conversionRate": 13.3 },
        { "hour": 12, "cars": 104, "retailCars": 44, "memberCars": 60, "eligible": 44, "sales": 4, "conversionRate": 9.1 }
      ]
    }
  },
  "denied_fields": [],
  "freshness": {
    "source": "rinsed_hourly",
    "source_name": "Rinsed Hourly (Demand)",
    "last_synced_at": "2026-08-10T04:02:55.117430",
    "state": "fresh",
    "is_stale": false,
    "sources": [
      {
        "source": "rinsed_hourly",
        "source_name": "Rinsed Hourly (Demand)",
        "last_synced_at": "2026-08-10T04:02:55.117430",
        "status": "idle",
        "state": "fresh",
        "retired": false,
        "is_stale": false
      }
    ]
  },
  "meta": { "site_count": 1, "min_days_for_p90": 3, "generated_at": "2026-08-10T17:53:33Z" }
}
Field Notes
sites[] {id, name, open_hour, close_hour}
hours[] always 7 … 20 inclusive, 14 entries, for every site and every request. The grid spans the portfolio window so mini-grids share an axis; a site opening at 9 shows genuinely-closed 7am/8am cells rather than the grid changing width
weekdays[] always the seven English names, Monday first
range.start / .end the effective pattern window
range.calendar_start / .calendar_end the narrower window that fed calendar: min(28 days, your span) ending at end. null in the empty-sites response
pattern {site_id_AS_STRING: {weekday: [cell, …]}}
calendar {site_id_AS_STRING: {date: [cell, …]}}. Dates with no rows are absent entirely
denied_fields[] sorted cell fields withheld by KPI scope; [] for an all-KPI key
freshness the hourly variant — a different shape from the KPI endpoints. See §8.6
meta.min_days_for_p90 3 — the threshold at which p90 stops being null

Structural traps:

  • Site IDs are stringified in pattern and calendar, because they are JSON object keys.
  • All seven weekday keys are always present, but a weekday with no observations is [].
  • Cell arrays are sparse. An hour with no observations is omitted, so an array holds 0–14 cells in ascending hour order. Index by each cell's hour field, never by array position.
  • open_hour / close_hour are inferred, not configured. They are MIN(hour) / MAX(hour) where car_count > 0 over a 180-day lookback from the server's todaynot over your requested range. Changing start_date/end_date does not change them. A site with no hourly activity at all is reported as 7/20 by fallback, which is indistinguishable from a site genuinely open 14 hours. And because they are data-derived, one stray 5am wash moves open_hour to 5 — which is outside hours[]. Do not assume open_hour ∈ hours.
  • open_hour / close_hour are not scope-filtered. They derive from car_count but are disclosed to any key holding the dataset grant, even one whose KPI scope excludes car_count.
  • The empty-sites response has a different shape: sites: [], pattern: {}, calendar: {}, range.calendar_start and .calendar_end both null, and meta.min_days_for_p90 is absent. Treat it as optional in your parser.

Timezone: date and hour are stored and returned in site-local time. Do not apply any UTC conversion to hour. The default window and generated_at, by contrast, are UTC, and open_hour/close_hour derive from a server-local today. See §2.1 for the full picture.

7.4 Scope filtering inside the grid

The dataset grant is coarse yes/no. Inside it, the key's KPI scope still filters individual cell fields:

Cell field Requires KPI-scope access to
cars car_count
retailCars retail_car_count
memberCars membership_car_count
sales membership_sales
eligible conversion_rate
conversionRate conversion_rate
hour, n, p90 (never filtered)

eligible and conversionRate derive from a stored metric, conversion_eligible, which is deliberately not a dashboard KPI (it is a derivation denominator), so both are gated on conversion_rate rather than polluting the KPI registry with a raw denominator. They are granted and denied together.

Withheld fields are absent from the cell, not null. Use key-presence semantics ("cars" in cell), and read denied_fields[] to distinguish withheld by policy from no data.

Example with a key scoped to scope_kpis: ["car_count"] plus the dataset grant:

{
  "pattern":  { "3": { "Monday": [ { "hour": 9, "n": 9, "p90": 41, "cars": 33 } ] } },
  "calendar": { "3": { "2026-07-31": [ { "hour": 9, "cars": 38 } ] } },
  "denied_fields": ["conversionRate", "eligible", "memberCars", "retailCars", "sales"]
}

Known scope gap, stated plainly: p90 is a car_count statistic but sits in the never-filtered set. A key scoped to, say, membership_sales alone will have cars stripped and listed in denied_fields, yet still receives p90 — the 90th-percentile car count.

7.5 The cell object

Pattern cell (averaged across the range):

{ "hour": 12, "n": 9, "p90": 118, "cars": 96, "retailCars": 40,
  "memberCars": 56, "eligible": 40, "sales": 5, "conversionRate": 11.7 }

Calendar cell (raw, per date) — no n, no p90:

{ "hour": 12, "cars": 104, "retailCars": 44, "memberCars": 60,
  "eligible": 44, "sales": 4, "conversionRate": 9.1 }
Field Type Present in Meaning
hour int 7–20 both hour-of-day bucket, site-local
cars int | null both total washes
retailCars int | null both non-member transactions
memberCars int | null both membership redemption washes
eligible int, never null both conversion denominator: washes eligible for a membership pitch
sales int, never null both new membership sign-ups
conversionRate float, 1 dp, never null both percent. Pooled — see §7.6
n int pattern only number of contributing days in this (site, weekday, hour) bucket
p90 int | null pattern only 90th-percentile car_count across contributing days. See §7.7

null is not zero

cars, retailCars and memberCars are null when the metric was never synced, and 0 when the hour genuinely saw none. These are different facts and must be rendered differently.

retailCars: 0 says no retail traffic that hour — do not staff the sales position. retailCars: null says the retail/member split did not sync — we have no idea. Coercing the second into the first renders a failed query as a convincing wall of zero demand, and someone builds a schedule against it. Render null as grey/hatched "no data", distinct from a cold "no demand" zero.

Note the asymmetry: eligible and sales are coalesced to 0, not null. So conversionRate: 0.0 with eligible: 0 is ambiguous between "no eligible washes" and "conversion data did not sync". Check freshness.state before trusting a zero there.

One more: pattern volume metrics are truncated, not rounded — a true mean of 54.33 renders as 54. Summing cars across a weekday's cells will slightly under-report versus the daily car_count KPI.

7.6 Rule 1: conversion is POOLED, never averaged

Averaging hourly percentages overweights quiet hours — a 9am with 4 eligible washes would count as much as a Saturday noon with 90. In pattern, conversionRate is therefore pooled: each day's own rate is weighted by that day's own eligible count. Days with eligible == 0 are excluded from the pool entirely.

You cannot reproduce conversionRate from the cell's own sales and eligible. Concrete example — three Mondays at hour 12 with (cars, sales, eligible) of (10, 0, 4), (100, 9, 90), (53, 1, 10):

pattern cell → { "cars": 54, "eligible": 34, "sales": 3,
                 "conversionRate": 9.6, "p90": 100, "n": 3 }

  average of the three hourly rates   →  6.67 %   ← wrong (quiet hour dominates)
  cell.sales / cell.eligible (3 / 34) →  8.8  %   ← also wrong (truncated means)
  pooled: (0+9+1) / (4+90+10)         →  9.6  %   ← what the API returns

Two distinct traps: averaging the rates understates conversion by about 30% here, and dividing the cell's own sales by its own eligible is also wrong, because in pattern both are truncated integer means, not sums.

Treat conversionRate as authoritative. Never derive it. Never average it across cells. If you must aggregate (e.g. roll several hours into a daypart), re-pool from the calendar raw values: SUM(sales) / SUM(eligible). In calendar, and only there, sales / eligible * 100 does reproduce conversionRate, because those are single-hour raw values.

7.7 Rule 2: p90 is withheld below three observations

p90 is the "busy-day peak" a staffing model sizes against. A peak drawn from one or two days is noise wearing a number, so a bucket with n < 3 reports p90: null. The threshold is echoed as meta.min_days_for_p90 so you can render "insufficient history" rather than inventing a number.

Do not fill a null p90. Substituting cars (the mean) erases the entire point of a peak statistic and will understaff; substituting the single observed value manufactures a distributional statistic out of one Monday.

Always read p90 alongside n. Two honest caveats about small samples:

  • The percentile is nearest-rank, so for n of 3, 4 or 5 it resolves to the observed maximum. At n = 3 with car counts [10, 53, 100] it returns 100. At n = 6 it lands one rank below the maximum — round(0.9 × 5) is 4 under Python's banker's rounding, selecting the 5th of 6 sorted values — and from n = 6 upward it behaves like a genuine percentile. So "small-n p90 == the busiest day we saw" holds for n of 3–5 only; do not assume it at n = 6, which is a common bucket size in a 6–8 week pattern window.
  • A day whose car_count failed to sync contributes a 0 to the p90 distribution, whereas the cars mean skips it. So p90 can be dragged down by missing data in a way cars is not.

7.8 Errors

Status code Trigger
400 BAD_REQUEST half a date pair; impossible date; start > end; span > 120 days; bad or > 100 site IDs
401 UNAUTHORIZED auth (and every OPTIONS)
403 FORBIDDEN dataset not granted, or no readable cell field — two distinct messages
429 RATE_LIMITED cost 10 against the per-key budget, or the flood guard
500 INTERNAL_ERROR Failed to retrieve demand heat-map.
503 BUSY heavy slot occupied, Retry-After: 5

There is no 404 — the route has no path parameter.


8. Freshness and provenance

Every value-bearing response carries a freshness block. It is not decoration. The aggregate queries coalesce missing rows to 0, so a total connector outage would otherwise publish as a genuine zero-revenue day, with the API certifying it as current. The freshness block detects connector-level outage — it is how you tell "the number is 0" from "the pipeline that produces this number is down".

Check freshness.state before publishing any number downstream. A zero with state: "stale" or state: "unknown" is not a zero — it is an absence of evidence.

But read §8.1a before you rely on it. state: "fresh" is a claim about a connector's health right now, not about whether every day and every site in your range actually has rows.

Staleness threshold: 36 hours since the source's last successful sync.

8.1 The four states, and what to do in each

state Means is_stale What your client should DO
fresh Every still-live source synced within 36 h and none is in an error state false Publish the number. This is the only state where you have both data and a current pipeline behind it.
stale At least one live source is in status: error, or more than 36 h past its last success, or reporting a future timestamp (clock skew) true The value may be a partial or frozen picture. Surface a staleness banner with last_synced_at. Do not alert on the value itself — especially not on a drop toward zero — until the pipeline is healthy. Do not write it into a downstream warehouse as a settled fact.
unknown A source has never synced, its timestamp is unparseable, or it has no connector_state row at all true Exactly as untrusted as stale, but do not display "last synced N hours ago" — there is no timestamp to show. Say "sync status unknown".
no_data No rows at all matched your KPI / date range / site filter, so there is no provenance to resolve false The empty or zero result is a genuine absence of records, not a claim that the period was zero and not a sync failure. Render "no data for this period", never "0 cars washed". source is null, sources and retired_sources are [], components is null.

Things that trip people up:

  • is_stale means "not verifiably current", so it covers unknown too. It is true for both stale and unknown, and false for fresh, retired and no_data. Not knowing whether data is current is not the same as knowing that it is, and the two must not be conflated — an earlier revision reported is_stale: false for a never-synced connector, which presented a fabricated zero as a real one.
  • no_data reports is_stale: false deliberately: reporting an empty result as "stale" is as wrong as reporting it as current. There is simply nothing to vouch for. Use state, not is_stale, to tell the two apart.
  • retired never makes data stale. A decommissioned connector still owns the rows it wrote; its frozen timestamp says nothing about whether today's data landed. Retired sources are listed separately in retired_sources.
  • state: "fresh" with an empty result is a different claim from no_data. Staleness is judged per connector, not per row: a connector syncing happily all week reports fresh even if it produced no rows for the specific sites and dates you asked about. That combination means "the pipeline is healthy and there genuinely were no records" — see §8.1a.
  • Clock skew folds into stale. A future last_synced_at is a fault in its own right and is reported as stale, not as a distinct state.

8.1a What freshness does NOT tell you: partial coverage

state: "fresh" means the connector is healthy now. It makes no claim that every day, or every site, in your range has rows.

resolve_freshness reads connector_state.last_success_at. It never looks at row coverage of the requested window. Two consequences that bite:

  • A gap in the middle of your range is invisible. A connector that was down for ten days last month but synced an hour ago reports fresh. The aggregate sums only the days that exist, so /kpis/revenue/value?period=mtd returns a silently under-reported total, certified as current.
  • A site with no rows at all is invisible. /kpis/car_count/value?sites=3,4 where site 4 never reported still returns fresh and echoes site_ids: [3, 4], as if both contributed.

/kpis/{id}/value and /kpis/values expose no row count, no day count and no contributing-site list, so the gap is undetectable from those responses.

Detection recipe. For any sum you intend to publish, count the rows yourself:

expected = len(kpi_ids) × len(sites) × days_in_range

# Option A — one bulk call for the same window:
bulk = GET /kpis/bulk?kpi_ids=…&sites=…&start_date=…&end_date=…
if bulk.meta.record_count < expected:  →  MISSING DATA, not a low number

# Option B — per KPI, cheaper to reason about:
ts = GET /kpis/{id}/timeseries?view=org&start_date=…&end_date=…
if len({p.date for p in ts.points}) < days_in_range:  →  MISSING DAYS

Treat a shortfall as missing data, never as a genuinely low value. Note the two legitimate reasons a count can fall short without anything being wrong: a formula KPI drops (date, site) rows whose denominator is missing or zero (§6.6), and a site that is genuinely closed on a date has no row. Both still deserve a look rather than a silent sum.

8.2 The block, field by field

Emitted on /kpis/{id}/value, every entry of /kpis/values, /kpis/{id}/timeseries, and per-KPI under freshness on /kpis/bulk.

Field Type Meaning
source string | null Connector ID of the headline source: the still-live source with the newest sync. null in two situations — no_data, and a formula KPI drawing on more than one distinct connector
source_name string | null Human connector name; literally the string "multiple" when more than one distinct live connector contributed. In the no_data case it is a vendor label instead ("Rinsed", "MaintainX") — a different namespace. Never key logic off this string
last_synced_at string | null For a stored KPI, the headline source's timestamp. For a formula KPI, the OLDEST contributing timestamp. Raw DB value — see §8.5
state enum the four states above
is_stale bool true when state is stale or unknown — i.e. whenever the data is not verifiably current. false for fresh, retired and no_data. The same rule applies to each entry in sources[], so a source's is_stale never disagrees with the block's
sources[] array every contributing source, retired ones included: {kpi_id, source, source_name, last_synced_at, state, retired, is_stale}. kpi_id is the leaf KPI, so on a formula KPI it identifies which component this source fed
retired_sources[] string[] sorted, de-duplicated connector IDs of decommissioned contributors
components[] array | null for formula KPIs, the same array as sources[]; null for stored KPIs

Note: entries inside sources[] can carry state: "retired", a fifth value that the top-level state never uses. And sources[] is not de-duplicated by connector in this variant — a connector feeding two leaves of a formula KPI appears twice, once per kpi_id.

8.3 Retired sources

A connector that has been decommissioned still owns the rows it wrote. Its state record is frozen at the day it was retired, so judging it against the 36-hour threshold would permanently mark historical data as stale while its live replacement syncs normally.

So: retired sources are excluded from the staleness judgement whenever at least one live source contributed. They still appear in sources[] (with state: "retired", retired: true) and are named in retired_sources[]. They are also excluded from headline-source selection.

"freshness": {
  "source": "rinsed",
  "source_name": "Rinsed",
  "last_synced_at": "2026-08-10T05:14:22.481903",
  "state": "fresh",
  "is_stale": false,
  "sources": [
    { "kpi_id": "self_serve", "source": "firebase_selfserve", "source_name": "Firebase Self-Serve", "last_synced_at": "2025-11-02", "state": "retired", "retired": true,  "is_stale": false },
    { "kpi_id": "self_serve", "source": "cryptopay",          "source_name": "CryptoPay",           "last_synced_at": "2026-08-10T05:14:22.481903", "state": "fresh",   "retired": false, "is_stale": false }
  ],
  "retired_sources": ["firebase_selfserve"],
  "components": null
}

retired_sources[] is provenance, not an alarm. It tells you part of the range was produced by a decommissioned system — useful for explaining a methodology break at the cut-over date. It must not drive a staleness warning.

Edge case: if every contributing source is retired, there is no live source to judge, so the retired ones' frozen timestamps do drive the state. That is correct — nothing is currently maintaining that data.

8.4 Formula KPIs and components[]

Six KPIs are ratios of other KPIs: awp, retail_awp, member_awp, labor_cpc, labor_pct_sales, churn_rate. Their dependencies are resolved recursively down to stored KPIs.

"freshness": {
  "source": null,
  "source_name": "multiple",
  "last_synced_at": "2026-08-08T05:02:19.310884",
  "state": "fresh",
  "is_stale": false,
  "sources": [
    { "kpi_id": "labor_cost", "source": "sonnys",  "source_name": "Sonny's Heatwave", "last_synced_at": "2026-08-08T05:02:19.310884", "state": "fresh", "retired": false, "is_stale": false },
    { "kpi_id": "car_count",  "source": "rinsed",  "source_name": "Rinsed",           "last_synced_at": "2026-08-10T05:14:22.481903", "state": "fresh", "retired": false, "is_stale": false }
  ],
  "retired_sources": [],
  "components": [ "…identical to sources…" ]
}

Three behaviours to build around:

  1. components[] is populated only for formula KPIs and is identical to sources[]. For stored KPIs it is null. Each entry's kpi_id names the leaf, so you can see that labor_cpc drew labor_cost from Sonny's and car_count from Rinsed.
  2. last_synced_at reports the OLDEST component, while source / source_name describe the NEWEST live source. They intentionally describe different things. A ratio is only as current as its least-current input: if revenue synced an hour ago but car_count last landed three days ago, then awp is a three-day-old number, and reporting the newest component would let you certify a stale ratio as current. is_stale follows the same logic — true if any component is stale. When more than one distinct connector contributes, source is forced to null and source_name to "multiple", because no single connector is the answer.
  3. components[] only lists leaves that actually returned rows. A formula KPI whose denominator returned nothing shows a components[] containing only the numerator, with no explicit marker for the absent one. To verify full coverage, compare components[].kpi_id against the KPI's formula from /catalog.

8.5 The timestamp format — read this before parsing

last_synced_at and last_success_at are emitted verbatim from the database. They are never normalized, never Z-suffixed, and are not always a timestamp.

You will receive one of three forms:

Emitted Origin
"2026-08-10T05:14:22.481903" a real sync timestamp — naive UTC, no Z, with microseconds
"2026-08-09" a legacy bare-date fallback for connectors predating the timestamp column
null the connector has never completed a successful run

Two consequences:

  • Interpret them as UTC even though they carry no designator. In JavaScript, new Date("2026-08-10T05:14:22.481903") parses as local time while new Date("2026-08-09") parses as UTC — so a naive parse is wrong in one direction for one form and right for the other. Append Z (or parse explicitly) before converting.
  • Length-check before parsing. A 10-character value is a date, not a timestamp. A bare date is read as midnight, so such a source appears up to ~24 h older than it is and can tip into stale spuriously.

Contrast with generated_at and server_time, which are normalized and Z-suffixed (2026-08-10T17:52:46Z). Only the sync timestamps are raw.

8.6 The heat-map freshness variant

/demand/heatmap uses a separate implementation with a different shape. Do not write one parser for both.

Hourly rows come from a different connector on a different cadence (rinsed_hourly, every 12 hours) than the daily rinsed connector, so resolving hourly provenance through the daily path would report the wrong connector's timestamp — certifying two-day-old hourly data as current.

KPI endpoints Heat-map
source, source_name, last_synced_at, state, is_stale present present
sources[] present present
retired_sources[] present absent
components[] present (null for stored KPIs) absent
sources[] entry keys kpi_id, source, source_name, last_synced_at, state, retired, is_stale source, source_name, last_synced_at, status, state, retired, is_staleno kpi_id, plus status
last_synced_at oldest component for formula KPIs always the headline (newest live) source
de-duplication one entry per (leaf, connector) pair one entry per distinct connector

The no_data variant returns only six keys — source, source_name, last_synced_at, state, is_stale, sources — with source_name: null.

Practical note on the threshold. The hourly connector runs every 12 hours and the upstream warehouse itself lags 24–48 h. The 36-hour threshold measures when the connector last ran successfully, not how recent the underlying data is. A fresh heat-map can legitimately still be missing yesterday's hours.

8.7 The cheap way to monitor all of this

GET /health (cost 1) gives the same picture across every connector and returns status: "degraded" when anything is stale. That is the right thing to alert on, rather than inferring pipeline health from individual freshness blocks.


9. Errors

Every error response is exactly:

{ "error": "<human-readable message>", "code": "<MACHINE_CODE>" }

9.1 The complete table

HTTP code When it happens What your client should do
400 BAD_REQUEST Impossible date (2026-02-31); half a date pair; start_date > end_date; span over 366 (or 120 on the heat-map); unknown period keyword; malformed or > 100 site IDs; > 50 kpi_ids; missing kpi_ids; projected result over 50,000 cells; bad shape or view Never retry. Deterministic. Fix the request.
401 UNAUTHORIZED Missing, unknown, expired, revoked or disabled key; any OPTIONS request Never retry. Alert a human — the credential is bad.
403 FORBIDDEN KPI outside scope; global KPI on a site-restricted key; demand_hourly not granted; no permitted heat-map field Never retry. An admin must widen the key.
404 NOT_FOUND Unknown kpi_id; OpenAPI spec unavailable Never retry.
405 METHOD_NOT_ALLOWED Any method other than GET, HEAD or OPTIONS — the API is read-only. Returns the JSON envelope, not an HTML page. Note HEAD does not 405 (it runs the full handler at full cost) and OPTIONS returns 401 Never retry. Fix the method.
429 RATE_LIMITED The per-key budget, or the pre-auth flood guard Retry, honouring Retry-After, with jitter.
500 INTERNAL_ERROR Handler failure Retry with exponential backoff.
503 BUSY A heavy read is already in flight on this worker. Retry-After: 5 Retry in ~5 s with generous jitter. Do not widen concurrency.
503 DB_UNAVAILABLE Transient database fault (mount hiccup, fd exhaustion). Retry-After: 10 Retry, honouring Retry-After.
503 UNAVAILABLE Generic service-unavailable fallback Retry with backoff.

Disambiguate the three 503s on code, not on status. All are worth retrying, but they mean different things and two of them carry different Retry-After values.

Endpoint-specific 500 messages: Failed to retrieve KPI value., Failed to retrieve KPI values., Failed to retrieve KPI timeseries., Failed to retrieve bulk KPI data., Failed to retrieve demand heat-map.

One header exception: a 405 carries no RateLimit-* headers and is not written to the key's usage log, because routing fails before the API's hooks bind.

9.2 503 BUSY versus 429

A single-occupancy slot per server worker guards the three heavy endpoints — /kpis/bulk, /kpis/{id}/timeseries and /demand/heatmap. Acquisition is non-blocking: a second heavy read is shed immediately rather than queued.

HTTP/1.1 503 Service Unavailable
Retry-After: 5

{ "error": "Server is busy serving another large request. Retry shortly.", "code": "BUSY" }

Shedding beats queueing because the server runs synchronous workers: a request occupying one for minutes makes the whole process unavailable, including its health probe, which then prompts the platform to recycle the instance mid-pull. A client retrying in 5 s is strictly better than one holding a worker to its timeout.

429 RATE_LIMITED 503 BUSY
Cause You exceeded your budget Someone (possibly you) is mid-flight on a heavy read on this worker
Scope Per API key, cross-worker, durable Per worker process, in-memory, instantaneous
Affects other keys? No Yes — the slot is shared by every caller on that worker
Endpoints All Only bulk / timeseries / heatmap
Retry-After From the window boundary; up to 60 s (minute) or hours (day) Always 5
RateLimit-* Present, Remaining: 0, plus RateLimit-Policy Present, and Remaining is > 0 — you had budget; the server was busy
Budget consumed? Yes Yes — full endpoint cost, already charged
Right response Back off for Retry-After; consider slowing the whole client Retry in ~5 s with generous jitter
Persistent occurrence means Your key needs a higher limit, or you are over-polling You are running heavy pulls in parallel — serialize them

9.3 Which error wins

Gates are evaluated in this fixed order:

  1. OPTIONS gate → 401
  2. Pre-auth flood guard → 429
  3. Token missing / unknown / expired / revoked → 401
  4. Per-key rate limit → 429 — before every scope and validation check
  5. Route: unknown KPI → 404; KPI or dataset scope → 403
  6. Parameter validation → 400
  7. Heavy slot → 503 BUSY
  8. Handler

So a throttled request that also names a nonexistent KPI returns 429, not 404. Fix throttling first; only then will the underlying errors become visible.


10. Limits and quotas

Limit Value Applies to Exceeding it gives
kpi_ids per request 50 /kpis/values, /kpis/bulk 400 Too many KPI IDs (max 50).
Site IDs per request 100 the five endpoints taking sites (§6.0) 400 Too many site IDs (max 100).
Date span 366 days every date-taking endpoint except the heat-map 400 Date range exceeds 366 days.
Date span, heat-map 120 days /demand/heatmap 400 Date range exceeds 120 days.
Projected result size 50,000 data points (kpis × sites × days, computed from parameters before any row is read). sites here is len(site_ids) after scope intersection, or the server's total active-site count when you omit sites — so send sites explicitly if you want to predict it /kpis/{id}/timeseries, /kpis/bulk 400 Requested approximately N data points, which exceeds the 50,000 limit. …
Per-key rate limit, minute 120 cost units / 60 s (overridable per key) all endpoints 429 RATE_LIMITED
Per-key rate limit, day 50,000 cost units / 86,400 s (overridable, resets 00:00 UTC) all endpoints 429 RATE_LIMITED
Pre-auth flood guard 600 requests / 60 s per IP per worker the whole API 429 RATE_LIMITED, no RateLimit-Policy
Concurrent heavy reads 1 per server worker bulk / timeseries / heatmap 503 BUSY, Retry-After: 5
Staleness threshold 36 hours freshness classification state: "stale"
p90 minimum sample 3 days heat-map pattern cells p90: null
Usage-log retention 90 days admin audit view rows pruned

Budget arithmetic at the defaults: 120 ÷ 10 = 12 bulk or heat-map calls per minute; 50,000 ÷ 10 = 5,000 per day. If your workload does not fit, ask an admin to raise rate_limit_per_min / rate_limit_per_day on the key rather than sharding across multiple keys.

10.1 Backfilling a large history

"Prefer fewer, larger requests" runs into the 50,000-cell cap almost immediately at portfolio scale. With 15 active sites:

KPIs per call × sites × days Projected cells Verdict
10 15 366 54,900 400 — and you have already spent 10 cost units
9 15 366 49,410 fits, and is the largest 366-day call available
20 15 183 (½ year) 54,900 400
18 15 183 49,410 fits

Partition by kpi_ids, not by date. A full year of N KPIs costs ceil(N / 9) bulk calls:

SITES = [3, 4, 9, 12, ...]          # always send them explicitly
DAYS  = 366
BATCH = 50_000 // (len(SITES) * DAYS)      # 9 at 15 sites × 366 days
assert BATCH >= 1, "narrow the date range: one KPI does not fit"

for i in range(0, len(kpi_ids), BATCH):
    batch = kpi_ids[i:i + BATCH]
    client.get("/kpis/bulk", {
        "kpi_ids": ",".join(batch),
        "sites": ",".join(map(str, SITES)),
        "start_date": start.isoformat(),
        "end_date": end.isoformat(),
    })

Cost and wall-clock. Each call costs 10, so the minute budget allows 12 calls/minute and the daily budget 5,000. Pulling all 55 KPIs for a year at 15 sites is ceil(55 / 9) = 7 calls — 70 cost units, comfortably inside one minute's budget, though each is a heavy read so run them serially (concurrency > 1 mostly returns 503 BUSY, and each shed call still costs 10). A five-year backfill is 35 calls, 350 cost units: budget-trivial, but ~35 sequential heavy reads, so size your client timeout accordingly (below).

10.2 Request sizing, timeouts and caching

Property Value
Server request timeout 120 s (Gunicorn --timeout=120, 4 sync workers)
Recommended client timeout 150–180 s on /kpis/bulk, /kpis/{id}/timeseries and /demand/heatmap; comfortably above the server's, so a legitimately slow response is not misread as a network fault
Maximal shape=records body ~6 MB uncompressed at 50,000 records (~120 bytes/record, six keys each)
Maximal shape=matrix body ~1 MB uncompressed at 50,000 cells
Compression None. No compression middleware is installed. Sending Accept-Encoding: gzip changes nothing — budget for the uncompressed size
Caching / conditional requests None. No ETag, no Last-Modified, no Cache-Control on any response. If-None-Match / If-Modified-Since are ignored, so re-pulling a settled historical window costs full price every time. Cache settled windows on your side

A client-side timeout on a heavy endpoint has already spent its full cost, and the retry spends it again. A client that treats a slow bulk call as a network error and retries three times has burned 40 cost units for zero rows. Set the timeout above 120 s and treat a timeout as a signal to narrow the request, not to retry it unchanged.


11. Worked example: a Python client

Pulls a month of KPIs for two sites, with correct auth, header-driven pacing, back-off across every retryable status, scope-denial detection, and freshness gating before anything is published downstream.

Standard library only — no dependencies. Run it with:

export NEXUS_API_BASE="https://rapid-elt.azurewebsites.net/api/external/v1"
export NEXUS_API_KEY="nxk_live_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX"
python3 nexus_pull.py
#!/usr/bin/env python3
"""Pull one month of KPIs for two sites from the NEXUS external API.

Demonstrates the client contract end to end:
  - Authorization: Bearer auth
  - proactive pacing from the RateLimit-* headers (cost units, not requests)
  - back-off for 429 / 503 BUSY / 503 DB_UNAVAILABLE / 5xx, with jitter
  - terminal handling of 400 / 401 / 403 / 404 / 405
  - reading denied[] and unknown[] on a 200
  - refusing to publish a number whose freshness state is not 'fresh'
"""

from __future__ import annotations

import json
import os
import random
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from datetime import date, timedelta

BASE = os.environ.get(
    "NEXUS_API_BASE", "https://rapid-elt.azurewebsites.net/api/external/v1"
).rstrip("/")
TOKEN = os.environ.get("NEXUS_API_KEY")

MAX_ATTEMPTS = 5          # per logical request
BACKOFF_BASE = 1.0        # seconds
BACKOFF_CAP = 60.0        # never sleep longer than this per RETRY attempt
PACE_FLOOR = 0.20         # pause proactively below 20% of the bucket
PACE_CAP = 300.0          # never sleep longer than this when pacing (§5.4):
                          # RateLimit-Reset on the DAILY bucket is seconds
                          # until 00:00 UTC — up to 86,400. Sleeping that
                          # verbatim is indistinguishable from a hung process.
TIMEOUT = 180             # above the server's own 120 s worker timeout, so a
                          # slow-but-valid heavy read is not misread as a
                          # network fault and retried at full cost (§10.2)

# Cost weights, so pacing knows what a call will actually charge.
ENDPOINT_COST = {
    "/kpis/bulk": 10,
    "/demand/heatmap": 10,
    "/kpis/values": 3,
}

TERMINAL_STATUSES = {400, 401, 403, 404, 405}


class NexusError(RuntimeError):
    """A terminal API error. Retrying will not help."""

    def __init__(self, status: int, code: str, message: str):
        super().__init__(f"HTTP {status} {code}: {message}")
        self.status = status
        self.code = code
        self.message = message


def _cost_for(path: str) -> int:
    if "/timeseries" in path:
        return 5
    return ENDPOINT_COST.get(path, 1)


def _jitter(seconds: float, fraction: float) -> float:
    return seconds + random.uniform(0.0, seconds * fraction)


class NexusClient:
    """Minimal, correct NEXUS external API client.

    Cooldown state is per key. If you run several worker processes against one
    token, move `_cooldown_until` into shared storage (Redis, a DB row) so the
    whole fleet pauses together — the budget is per key, not per process.
    """

    def __init__(self, base: str, token: str):
        if not token:
            raise SystemExit("Set NEXUS_API_KEY to your nxk_live_... token.")
        self.base = base
        self.token = token
        self._cooldown_until = 0.0
        # Last observed budget, from the RateLimit-* headers. Needed because
        # Remaining is in COST units: a cost-10 call needs 10 of them (§5.3).
        self._remaining: int | None = None
        self._reset: float = 0.0

    # -- transport ---------------------------------------------------------

    def get(self, path: str, params: dict | None = None) -> dict:
        url = self.base + path
        if params:
            url += "?" + urllib.parse.urlencode(params)
        cost = _cost_for(path)

        for attempt in range(1, MAX_ATTEMPTS + 1):
            self._await_cooldown(cost)
            req = urllib.request.Request(
                url,
                headers={
                    "Authorization": f"Bearer {self.token}",
                    "Accept": "application/json",
                    "User-Agent": "nexus-example-client/1.0",
                },
                method="GET",
            )
            try:
                with urllib.request.urlopen(req, timeout=TIMEOUT) as resp:
                    body = json.loads(resp.read().decode("utf-8"))
                    self._pace(resp.headers)
                    return body

            except urllib.error.HTTPError as exc:
                raw = exc.read().decode("utf-8", "replace")
                try:
                    payload = json.loads(raw)
                except ValueError:
                    payload = {}
                code = payload.get("code", "")
                message = payload.get("error", raw[:200])
                # Headers are present on most errors, absent on 401 and 405.
                self._pace(exc.headers)

                if exc.code in TERMINAL_STATUSES:
                    raise NexusError(exc.code, code, message) from None
                if attempt == MAX_ATTEMPTS:
                    raise NexusError(exc.code, code, message) from None

                wait = self._retry_delay(exc, attempt, code)
                print(
                    f"  [{exc.code} {code}] retry {attempt}/{MAX_ATTEMPTS - 1} "
                    f"in {wait:.1f}s",
                    file=sys.stderr,
                )
                if exc.code == 429:
                    # Pause siblings too, not just this in-flight call.
                    self._cooldown_until = max(
                        self._cooldown_until, time.time() + wait
                    )
                time.sleep(wait)

            except urllib.error.URLError as exc:
                if attempt == MAX_ATTEMPTS:
                    raise
                wait = min(BACKOFF_CAP, BACKOFF_BASE * 2 ** attempt)
                print(f"  [network] {exc.reason}; retry in {wait:.1f}s",
                      file=sys.stderr)
                time.sleep(_jitter(wait, 0.5))

        raise AssertionError("unreachable")

    def _retry_delay(self, exc, attempt: int, code: str) -> float:
        retry_after = exc.headers.get("Retry-After")
        reset = exc.headers.get("RateLimit-Reset")
        if retry_after and retry_after.isdigit():
            wait = float(retry_after)
        elif reset and reset.isdigit():
            wait = float(reset)
        else:
            wait = BACKOFF_BASE * 2 ** attempt
        wait = min(wait, BACKOFF_CAP)
        # 503 BUSY is contention: synchronised retries re-collide, so dither
        # it harder than a throttle, which is released on a clock boundary.
        return _jitter(wait, 0.5 if code == "BUSY" else 0.3)

    # -- pacing ------------------------------------------------------------

    def _pace(self, headers) -> None:
        """Proactive pacing off RateLimit-*. Absent headers = unknown budget."""
        limit = headers.get("RateLimit-Limit")
        remaining = headers.get("RateLimit-Remaining")
        reset = headers.get("RateLimit-Reset")
        policy = headers.get("RateLimit-Policy") or ""
        if not (limit and remaining and reset):
            return  # fail-open limiter, or a 401/405 — pace on our own terms
        try:
            limit_i, remaining_i, reset_i = int(limit), int(remaining), int(reset)
        except ValueError:
            return

        self._remaining = remaining_i
        # RateLimit-Reset is SECONDS REMAINING, not a Unix epoch.
        self._reset = float(reset_i)

        if "w=86400" in policy:
            # The daily bucket only becomes the reported one in the last ~120
            # cost units of the day (§5.4). Sleeping until midnight UTC is not
            # a strategy — stop and let an operator decide.
            print(f"  DAILY budget nearly exhausted ({remaining_i} units left, "
                  f"resets in {reset_i}s). Stop the run and alert.",
                  file=sys.stderr)

        if remaining_i <= PACE_FLOOR * limit_i:
            self._cooldown_until = max(
                self._cooldown_until, time.time() + min(self._reset, PACE_CAP)
            )

    def _await_cooldown(self, cost: int) -> None:
        # Enforce the rule from §5.3: Remaining is in cost units, so a cost-10
        # call must not be issued with 9 left — it would just 429 and still be
        # charged. This is a hard gate; PACE_FLOOR below is the softer one.
        if self._remaining is not None and self._remaining < cost:
            wait = min(self._reset, PACE_CAP)
            if wait > 0:
                print(f"  budget: {self._remaining} unit(s) left but the next "
                      f"call costs {cost}; sleeping {wait:.1f}s",
                      file=sys.stderr)
                time.sleep(wait)
                self._remaining = None      # stale after the window rolls

        wait = self._cooldown_until - time.time()
        if wait > 0:
            print(f"  pacing: sleeping {wait:.1f}s before a cost-{cost} call",
                  file=sys.stderr)
            time.sleep(wait)


# -- freshness gate --------------------------------------------------------

def publishable(kpi_id: str, freshness: dict) -> bool:
    """Decide whether a number is safe to write downstream.

    Missing rows aggregate to 0, so a zero from a stale or never-synced
    connector is indistinguishable from a real zero unless you check here.
    """
    state = freshness.get("state")
    if state == "fresh":
        return True
    if state == "no_data":
        print(f"  {kpi_id}: NO DATA for this window — record an absence, "
              f"not a zero.")
        return False
    if state == "stale":
        print(f"  {kpi_id}: STALE (last sync {freshness.get('last_synced_at')}, "
              f"source {freshness.get('source_name')}) — hold.")
        return False
    print(f"  {kpi_id}: sync status UNKNOWN — treat as untrusted, and do not "
          f"display a 'last synced' time.")
    return False


# -- the pull --------------------------------------------------------------

def main() -> int:
    client = NexusClient(BASE, TOKEN)

    # 1. Discover. Never hard-code site or KPI IDs — scope filters both lists
    #    silently, so these ARE the source of truth for what this key can read.
    sites = client.get("/sites")
    visible = {s["id"]: s["name"] for s in sites["sites"]}
    print(f"Key can see {len(visible)} site(s): {visible}")

    catalog = client.get("/catalog", {"department": "operations"})
    readable = {k["kpi_id"]: k for k in catalog["kpis"]}
    print(f"Key can read {len(readable)} operations KPI(s).")

    wanted_sites = [sid for sid in (3, 4) if sid in visible][:2]
    if not wanted_sites:
        wanted_sites = list(visible)[:2]
    if not wanted_sites:
        print("This key has no visible sites; nothing to pull.")
        return 1

    wanted_kpis = [k for k in ("car_count", "revenue", "awp") if k in readable]
    if not wanted_kpis:
        print("None of the requested KPIs are in this key's scope.")
        return 1

    # 2. A full previous calendar month, computed client-side. Explicit dates
    #    beat period keywords: they are unambiguous and timezone-independent.
    today = date.today()
    end = today.replace(day=1) - timedelta(days=1)
    start = end.replace(day=1)
    span = (end - start).days + 1

    # Stay inside the projected-size cap before spending 10 cost units on a 400.
    # This arithmetic is only valid because we send `sites` explicitly below.
    # Omit `sites` and the server projects against its OWN active-site count,
    # which the client cannot see (§6.6). Partition recipe: §10.1.
    projected = len(wanted_kpis) * len(wanted_sites) * span
    if projected > 50_000:
        print(f"Projected {projected:,} points exceeds the cap; narrow the pull.")
        return 1

    print(f"\nPulling {wanted_kpis} for sites {wanted_sites}, "
          f"{start} .. {end} ({projected:,} projected points)")

    bulk = client.get("/kpis/bulk", {
        "kpi_ids": ",".join(wanted_kpis),
        "sites": ",".join(str(s) for s in wanted_sites),   # 'sites', not 'site_ids'
        "start_date": start.isoformat(),
        "end_date": end.isoformat(),
        "shape": "records",
    })

    # 3. Scope outcomes arrive on a 200. Ignoring them reports zeros for KPIs
    #    you never received.
    if bulk["denied"]:
        print(f"DENIED (an admin must widen the key): {bulk['denied']}")
    if bulk["unknown"]:
        print(f"UNKNOWN (fix the request): {bulk['unknown']}")

    # 4. The site filter is intersected, never widened. Verify what you got.
    effective = bulk["site_ids"]
    if effective is not None and set(effective) != set(wanted_sites):
        print(f"NOTE: asked for sites {wanted_sites}, served {effective}")

    # 5. Gate on freshness per KPI, then aggregate only what is publishable.
    print("\nFreshness:")
    trusted = {k for k in bulk["freshness"]
               if publishable(k, bulk["freshness"][k])}

    totals: dict[tuple[str, int | None], float] = {}
    unresolved = 0
    for rec in bulk["records"]:
        if rec["kpi_id"] not in trusted:
            continue
        # A formula record's site_id can be None when the site name did not
        # resolve (§6.6). Keep it — dropping it would silently lose rows — but
        # never let it reach a comparison against an int.
        if rec["site_id"] is None:
            unresolved += 1
        key = (rec["kpi_id"], rec["site_id"])
        totals[key] = totals.get(key, 0.0) + (rec["value"] or 0.0)

    print(f"\n{bulk['meta']['record_count']} records; "
          f"{len(trusted)}/{bulk['meta']['kpi_count']} KPIs publishable.")
    if unresolved:
        print(f"  ({unresolved} record(s) had site_id=null — unresolved site "
              f"name; they are grouped separately below.)")

    # Null-safe sort: sorted() on (str, int|None) raises TypeError as soon as
    # one record carries site_id=None. Sort None last, deterministically.
    def _sort_key(item):
        (kpi_id, site_id), _total = item
        return (kpi_id, site_id is None, site_id or 0)

    for (kpi_id, site_id), total in sorted(totals.items(), key=_sort_key):
        unit = readable.get(kpi_id, {}).get("unit", "count")
        name = visible.get(site_id, site_id if site_id is not None else "(unresolved)")
        print(f"  {kpi_id:<12} {str(name):<18} {total:>14,.2f}  ({unit})")

    # 6. One cheap call that tells you whether the whole pipeline is healthy.
    health = client.get("/health")
    if health["status"] != "ok":
        print(f"\nPipeline DEGRADED — stale connectors: {health['stale_sources']}")

    return 0


if __name__ == "__main__":
    try:
        sys.exit(main())
    except NexusError as exc:
        # 401 -> the credential is bad. 403 -> an admin must widen the key.
        # 400/404/405 -> the request is wrong. None of these will heal.
        print(f"\nFATAL: {exc}", file=sys.stderr)
        sys.exit(2)

What to change for production use:

  • Move _cooldown_until, _remaining and _reset into shared storage if you run more than one process against one token. The budget is per key.
  • Track your own daily cost spend since 00:00 UTC and stop at a self-imposed threshold. The headers will not warn you in time (§5.4).
  • Keep heavy-endpoint concurrency at 1. Parallel bulk calls mostly produce 503 BUSY, and each shed call still costs 10.
  • Persist denied[] / unknown[] / non-fresh KPIs to your monitoring, not just stdout. They are the failure modes that arrive as 200s.
  • Handle site_id: null records deliberately. This example groups them under (unresolved); a warehouse loader should either resolve them by site name or quarantine them, never silently drop them (§6.6).
  • Check coverage, not just freshness. Compare meta.record_count against len(kpi_ids) × len(sites) × days before publishing a total (§8.1a).
  • Scale formula KPIs. awp is safe, but if you add labor_pct_sales or churn_rate to wanted_kpis, multiply each daily value by readable[kpi_id]["formula"]["multiplier"]/kpis/bulk does not apply it (§6.6).

12. Changelog and migration notes

For integrators upgrading an existing client. Several of these turn previously-silent successes into explicit errors. That is deliberate: each one was a case where the API returned 200 with an answer that did not mean what the client thought it meant.

12.1 site_idssites

sites is now the canonical spelling and is read first. site_ids still works everywhere and is not going away soon, but it is deprecated.

The earlier docs told clients to use site_ids on /kpis/bulk and sites elsewhere, as if they were different parameters on different endpoints. In fact the bulk endpoint only read sites at the time, so a client following the published example verbatim had its filter ignored and silently received every site the key could see. Both spellings are now accepted on every endpoint.

Action: rename to sites in new code, and — regardless of spelling — start comparing the echoed site_ids in the response against what you asked for.

12.2 Stricter validation: previously-silent 200s are now 400s

Input Before Now
2026-02-31, 2026-13-01 regex-passed, then blew up as a 500 400 BAD_REQUEST
Only one of start_date / end_date inconsistent — some paths substituted a default window 400 on every endpoint. All-or-nothing
sites=1,abc,2 the whole filter was dropped → all sites, 200 400 sites must be a comma-separated list of integer site IDs.
Unknown period keyword silently resolved to MTD, and the response echoed your bogus keyword back 400 Unknown period '<x>'. Valid values: …
period=last7, last14, last30, last90, this_week worked 400 — these are no longer in the allowlist
Date span over 366 days on /value or /values uncapped 400 Date range exceeds 366 days. — the cap now applies to all date-taking endpoints
More than 100 site IDs accepted 400 Too many site IDs (max 100).
A huge /bulk or /timeseries pull buffered, then timed out 400 with a projected-size message, computed before any row is read
Non-GET method Werkzeug's HTML page, so res.json() threw and masked the cause 405 with the JSON envelope and code: METHOD_NOT_ALLOWED
OPTIONS Flask answered it and leaked the route table via Allow 401

Action: replace last7 / last14 / last30 / last90 / this_week with explicit start_date + end_date. Note the seven keywords that are accepted but currently resolve to MTD (§6.0) and avoid those too.

12.3 freshness gained state, sources[] and retired_sources[]

The block previously carried only source, source_name, last_synced_at, is_stale and components. It now also carries:

  • statefresh | stale | unknown | no_data. Branch on this, not on is_stale.
  • sources[] — every contributing source, retired ones included.
  • retired_sources[] — decommissioned contributors. Provenance, not an alarm.
  • state and retired on every components[] / sources[] entry.

How each situation is now labelled — note that is_stale did not change for the first two rows, which is exactly why you must branch on state:

Situation Before Now
Connector has never synced is_stale: false state: "unknown", is_stale: true
last_synced_at unparseable is_stale: false state: "unknown", is_stale: true
No connector record at all for the stamped source is_stale: false state: "unknown", is_stale: true
Future timestamp (clock skew) is_stale: false state: "stale", is_stale: true
Empty result, no rows in range is_stale: false (indistinguishable from healthy) state: "no_data", is_stale: falsebut now explicitly labelled
Retired connector alongside a healthy replacement is_stale: true (permanently) excluded from the judgement; reported in retired_sources[]
Multi-connector KPI whose connector was replaced headline reported the retired predecessor headline is the freshest live source

The old behaviour meant a total connector outage published as a genuine zero-revenue day with the API certifying it as current.

Action: is_stale now covers unknown as well as stale, so existing alerting keyed on it will start firing for connectors that were previously silent — that is the intended correction, not a regression. For a display that distinguishes "we know it is old" from "we cannot tell", branch on f["state"] instead (§8.1).

Also: every example in the previous guide rendered last_synced_at as "2026-06-24T06:00:00Z". The database never stores a Z. The real emitted form is naive UTC with microseconds and no designator, or a bare YYYY-MM-DD, or null. Clients written against those examples mis-parse. See §8.5.

12.4 New: the demand_hourly dataset grant and /demand/heatmap

GET /demand/heatmap is new, and so is a fourth scope dimension, scope_datasets, whose default inverts the other three: unset means no access.

Every key that existed before this change has no dataset access and will receive 403 from the heat-map — including keys with no scope restrictions at all. An admin must grant demand_hourly explicitly, per key.

The previous guide's claim that "each key has three independent allowlists… an empty allowlist means 'no restriction' (all)" is now wrong on both counts: there are four, and the fourth does not work that way.

Action: if you need the heat-map, request the grant from an admin before you write the integration. There is no client-side workaround.

12.5 New: per-key rate limiting with cost weighting

Previously the only throttle was a loose per-IP, per-worker, in-process limit — with several workers each holding their own copy and recycling regularly, the real allowance was somewhere between several times the published number and unbounded.

Now: 120 cost units per minute and 50,000 per day, per API key, shared across all workers and surviving worker recycles, with endpoint cost weighting (bulk 10, heat-map 10, timeseries 5, values 3, everything else 1). Both are overridable per key by an admin. The loose per-IP guard still exists, at 600/60 s, purely to shed anonymous floods.

The previous guide's "~120 requests/minute (per source IP)" was wrong about the dimension, silent about the daily cap, silent about cost weighting, and silent about the response headers.

Action:

  • Read RateLimit-Limit / -Remaining / -Reset / -Policy on every response and pace proactively. Remember Remaining is in cost units and Reset is seconds remaining, not an epoch.
  • Recompute your call budget with the cost table (§5.3). A polling loop that fit in 120 requests/minute may not fit in 120 cost units/minute.
  • Expect that a fixed window permits up to 2× the limit across a boundary — do not build on it (§5.5).

12.6 New: 503 BUSY

Heavy reads (/kpis/bulk, /kpis/{id}/timeseries, /demand/heatmap) are now limited to one in flight per server worker, and a second is shed immediately with 503, code: "BUSY", Retry-After: 5.

Action: handle 503 and disambiguate on codeBUSY (retry in 5 s), DB_UNAVAILABLE (retry in 10 s) and UNAVAILABLE mean different things. Cap your heavy-endpoint concurrency at 1, and remember a shed request has already spent its full cost.