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
- What this API is, and what it is not
- Base URL and versioning
- Authentication
- Scopes
- Rate limits
- Endpoint reference
- The demand heat-map
- Freshness and provenance
- Errors
- Limits and quotas
- Worked example: a Python client
- 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 acceptsGETand, because of the framework's routing,HEAD— Werkzeug addsHEADto any rule declaringGET. AHEADrequest executes the full handler and is charged the full endpoint cost (it also takes the heavy-read slot and can return503 BUSY), so do not use it for liveness probing; useGET /health, cost 1.OPTIONSreturns401. Every other method returns405 METHOD_NOT_ALLOWED. - It is not a browser API. There is no CORS handling.
OPTIONSon any path returns401before 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 v1 — write 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
Bearerprefix match is case- and space-sensitive.bearer <token>does not match, and the request silently falls through toX-API-Key. Authorization: Bearerwith an empty value also falls through toX-API-Keyrather 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_RECENT — including 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:
- 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.
- Copy the token out of the show-once modal into your secret store immediately.
- 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.
- Confirm traffic has moved: check the old key's
last_used_at/request_countin Admin → API Keys. Usage rows are pruned after 90 days, so verify the cutover inside that window. - 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 a401. 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] |
9 — entirely 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(orsites[]) 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:
/catalogreflects 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 sixscope: "global"KPIs — and then gets403from/kpis/{id}/valueand/kpis/{id}/timeseries, or finds them indenied[]on/kpis/valuesand/kpis/bulk. Either filterentry["scope"] == "global"out yourself when you know the key is site-restricted, or tolerate the403/deniedoutcome.There is no scope-introspection endpoint. Nothing in this API tells a key its own
scope_sites,scope_kpis,scope_datasetsor rate-limit overrides, and/sitescannot 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 provoking403s (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 KPIThe
403above only fires for a site-restricted key. An unrestricted key that passessites=alongside a global KPI gets something worse: a silent zero.
/kpis/{id}/valueand/kpis/valuesapply the filter verbatim — unlike/kpis/{id}/timeseries, which forcesview=organdsite_ids: nullfor these KPIs. Global rows live against the syntheticGLOBALsite, 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. Omitsitesfor these six KPIs, or request them in a separate call. Thefreshness.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_datasetsinverts 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 FORBIDDENfrom/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 gatesGET /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:
RateLimit-Remainingis in cost units, not requests.Remaining: 20on a 120/min key means two more bulk calls, not twenty. Before issuing a cost-10 call, requireRemaining >= 10.- The charge is applied before the handler runs and is never refunded. A
request that ends in
400,403,404,429,500or503 BUSYhas still spent its full cost. A retried503 BUSYon/kpis/bulkcosts another 10. A401is the only status that costs nothing, because the limiter runs after the key resolves — plus two non-statuses: a405(routing fails before the API's hooks bind) and a flood-guard429(it runs before authentication). Retrying into a per-key429still charges, so obeyRetry-Afterrather than polling. - 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
HEADis charged exactly likeGET(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 seeRateLimit-Limit: 120all day, then it flips to50000when 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_COSTfor every call you make since00: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-Resetis 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
:00second (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 fromRetry-Afteralone is not enough.
5.6 Recommended back-off algorithm
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-Afterover 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 BUSYretries 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}/timeseriesand/demand/heatmapcontend for a single slot per server worker. Issuing them in parallel from one client mostly produces503 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
400that has already cost you 10 units. - Cap any pacing sleep.
RateLimit-Reseton the daily bucket is seconds until midnight UTC. A pacer that sleeps it verbatim hangs for up to a day. - Never retry a
400for an oversized projection. It is deterministic. Narrow the range, the sites or thekpi_idsand 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 |
400 — sites must be a comma-separated list of integer site IDs. |
sites=0 or a value above 2⁶³−1 |
400 — sites contains an invalid site ID. |
| more than 100 IDs | 400 — Too 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 todaytoday … today yesterdayyesterday … yesterday mtd1st of this month … today ytd1 January … today last_weekprevious Monday … previous Sunday last_month1st … last day of the previous month last_year1 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 explicitstart_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 | 400 — start_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 |
400 — start_date must be on or before end_date. |
| Inclusive span above the cap | 400 — Date 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.
/sitesand/healthdisagree on purpose./siteslists every connector state, including decommissioned ones, and computesis_stalewithout any retirement exemption — so a retired connector showsis_stale: truehere./healthskips retired connectors entirely, and thefreshnessblocks treat them asretiredrather 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.
sitesis applied verbatim here, including to organisation-wide KPIs. Unlike/timeseries, this endpoint performs no global-scope coercion, and global rows live against the syntheticGLOBALsite — sosites=2oncallsreturnsvalue: 0withfreshness.state: "no_data"andsite_ids: [2]echoed back. Omitsitesfor 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/valueshas no projected-size cap, so a 50-KPI × all-sites × 366-day request is accepted here at cost 3, while a single-KPI/kpis/bulkcosts 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_salesandchurn_ratecome back on two different scalesBoth carry
formula.multiplier: 100.view=orgapplies it;view=sitedoes not./kpis/{id}/valueapplies it./kpis/bulkdoes not. All four responses reportunit: "percentage", and nothing in the payload tells you which scale you are holding — a partner summing dailylabor_pct_salesfromview=siteand reconciling against the monthly/valueis wrong by two orders of magnitude with no signal.When reading daily formula rows, multiply by
formula.multiplierfrom/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 theview=site//kpis/bulkvalues will change scale, so key the multiplication off/catalograther than hard-coding100.Missing denominators:
siteandorgbehave in opposite ways.view=sitedrops the day;view=orgfabricates a0for it. Sopoint_countbelow your span is expected onview=siteand means missing data, while a0onview=orgis not evidence that the ratio was zero. Cross-check the component KPIs before trusting either.
churn_rateis not comparable across endpoints. Withdenominator_offset_months: -1,view=orgaccumulates the numerator against a single fixed denominator (a cumulative series, not a daily one),view=sitedivides by a per-site average of the offset window, and/kpis/{id}/valueaverages per-month rates. Three definitions of the same KPI. Do not reconcile churn across endpoints — pick one and stay on it.
Notes and caveats:
rangehas norequested_period, unlike/value'speriod.- Global-scope KPIs force
view=orgafter validation.view=siteoncallsis accepted, returns org points, and the response echoes"view": "org".site_idsis forced tonullfor these, because the filter was not applied and echoing it would misrepresent what the numbers cover. orgrollups sum unless the KPI'saggregation_methodisaverage. The threelatestKPIs —active_members,wo_active,headcount— are therefore summed across sites inview=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 bydate.
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 omitsitesentirely, it falls back to the server's total active-site count, a number your client cannot see. So the naive client-side prechecklen(kpis) * len(my_sites) * daysis wrong wheneversitesis omitted. Sendsitesexplicitly 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", not3). - Provenance and the site name are lost. There is no
sourceand nositein matrix mode. If you need to know which connector produced a value, userecords. meta.record_countstill counts the flat records, and any record whosesite_idisnull(a formula record whose site name did not resolve) is dropped from the matrix. So the number of values inmatrixcan be lower thanrecord_count. Do not userecord_countto validate matrix completeness.
Other notes:
freshnesshere is an object keyed bykpi_id, one full block per allowed KPI. Denied and unknown IDs are absent from it.meta.site_countisnullwhen 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'sfreshness[kpi].componentsfor the underlying connectors. - Formula values here are unscaled.
/kpis/bulkdoes not applyformula.multiplier, while/kpis/{id}/valueandtimeseries?view=orgdo.labor_pct_salesandchurn_ratetherefore arrive as raw fractions labelledunit: "percentage"— 100× smaller than the same KPI from/value. Multiply byformula.multiplierfrom/catalogbefore reconciling. Same known defect as in §6.5. - Formula records with a missing or zero denominator are omitted entirely,
with no
nullmarker — so a formula KPI legitimately returns fewer records thansites × days, and absence is not zero. (timeseries?view=orgdoes the opposite and emits a literal0.) churn_rateis computed a different way on each of/value,timeseries?view=organd 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/heatmap — cost 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
403to a key with no scope restrictions whatsoever.scope_datasetsis fail-closed: unset means no datasets, not all datasets. See §4.4.
Two checks run before anything else, in this order:
- Dataset grant. Without
demand_hourlyon 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.
- 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
periodkeyword. Sendingperiod=mtdis 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 →
200with 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
patternandcalendar, 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
hourfield, never by array position. open_hour/close_hourare inferred, not configured. They areMIN(hour)/MAX(hour)wherecar_count > 0over a 180-day lookback from the server's today — not over your requested range. Changingstart_date/end_datedoes not change them. A site with no hourly activity at all is reported as7/20by fallback, which is indistinguishable from a site genuinely open 14 hours. And because they are data-derived, one stray 5am wash movesopen_hourto 5 — which is outsidehours[]. Do not assumeopen_hour ∈ hours.open_hour/close_hourare not scope-filtered. They derive fromcar_countbut are disclosed to any key holding the dataset grant, even one whose KPI scope excludescar_count.- The empty-sites response has a different shape:
sites: [],pattern: {},calendar: {},range.calendar_startand.calendar_endbothnull, andmeta.min_days_for_p90is 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:
p90is acar_countstatistic but sits in the never-filtered set. A key scoped to, say,membership_salesalone will havecarsstripped and listed indenied_fields, yet still receivesp90— 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,retailCarsandmemberCarsarenullwhen the metric was never synced, and0when 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
conversionRateas authoritative. Never derive it. Never average it across cells. If you must aggregate (e.g. roll several hours into a daypart), re-pool from thecalendarraw values:SUM(sales) / SUM(eligible). Incalendar, and only there,sales / eligible * 100does reproduceconversionRate, 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
nof 3, 4 or 5 it resolves to the observed maximum. Atn = 3with car counts[10, 53, 100]it returns100. Atn = 6it lands one rank below the maximum —round(0.9 × 5)is4under Python's banker's rounding, selecting the 5th of 6 sorted values — and fromn = 6upward it behaves like a genuine percentile. So "small-np90== the busiest day we saw" holds fornof 3–5 only; do not assume it atn = 6, which is a common bucket size in a 6–8 week pattern window. - A day whose
car_countfailed to sync contributes a 0 to thep90distribution, whereas thecarsmean skips it. Sop90can be dragged down by missing data in a waycarsis 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.statebefore publishing any number downstream. A zero withstate: "stale"orstate: "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_stalemeans "not verifiably current", so it coversunknowntoo. It istruefor bothstaleandunknown, andfalseforfresh,retiredandno_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 reportedis_stale: falsefor a never-synced connector, which presented a fabricated zero as a real one.no_datareportsis_stale: falsedeliberately: reporting an empty result as "stale" is as wrong as reporting it as current. There is simply nothing to vouch for. Usestate, notis_stale, to tell the two apart.retirednever 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 inretired_sources.state: "fresh"with an empty result is a different claim fromno_data. Staleness is judged per connector, not per row: a connector syncing happily all week reportsfresheven 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 futurelast_synced_atis 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=mtdreturns a silently under-reported total, certified as current. - A site with no rows at all is invisible.
/kpis/car_count/value?sites=3,4where site 4 never reported still returnsfreshand echoessite_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:
components[]is populated only for formula KPIs and is identical tosources[]. For stored KPIs it isnull. Each entry'skpi_idnames the leaf, so you can see thatlabor_cpcdrewlabor_costfrom Sonny's andcar_countfrom Rinsed.last_synced_atreports the OLDEST component, whilesource/source_namedescribe the NEWEST live source. They intentionally describe different things. A ratio is only as current as its least-current input: ifrevenuesynced an hour ago butcar_countlast landed three days ago, thenawpis a three-day-old number, and reporting the newest component would let you certify a stale ratio as current.is_stalefollows the same logic —trueif any component is stale. When more than one distinct connector contributes,sourceis forced tonullandsource_nameto"multiple", because no single connector is the answer.components[]only lists leaves that actually returned rows. A formula KPI whose denominator returned nothing shows acomponents[]containing only the numerator, with no explicit marker for the absent one. To verify full coverage, comparecomponents[].kpi_idagainst the KPI'sformulafrom/catalog.
8.5 The timestamp format — read this before parsing
last_synced_atandlast_success_atare emitted verbatim from the database. They are never normalized, neverZ-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 whilenew Date("2026-08-09")parses as UTC — so a naive parse is wrong in one direction for one form and right for the other. AppendZ(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
stalespuriously.
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_stale — no 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:
OPTIONSgate →401- Pre-auth flood guard →
429 - Token missing / unknown / expired / revoked →
401 - Per-key rate limit →
429— before every scope and validation check - Route: unknown KPI →
404; KPI or dataset scope →403 - Parameter validation →
400 - Heavy slot →
503 BUSY - 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,_remainingand_resetinto 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 UTCand 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-freshKPIs to your monitoring, not just stdout. They are the failure modes that arrive as200s. - Handle
site_id: nullrecords deliberately. This example groups them under(unresolved); a warehouse loader should either resolve them bysitename or quarantine them, never silently drop them (§6.6). - Check coverage, not just freshness. Compare
meta.record_countagainstlen(kpi_ids) × len(sites) × daysbefore publishing a total (§8.1a). - Scale formula KPIs.
awpis safe, but if you addlabor_pct_salesorchurn_ratetowanted_kpis, multiply each daily value byreadable[kpi_id]["formula"]["multiplier"]—/kpis/bulkdoes 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_ids → sites
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:
state—fresh|stale|unknown|no_data. Branch on this, not onis_stale.sources[]— every contributing source, retired ones included.retired_sources[]— decommissioned contributors. Provenance, not an alarm.stateandretiredon everycomponents[]/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: false — but 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
403from the heat-map — including keys with no scope restrictions at all. An admin must grantdemand_hourlyexplicitly, 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/-Policyon every response and pace proactively. RememberRemainingis in cost units andResetis 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 code — BUSY (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.