---
title: "Notable Alerts"
method: GET
path: "/api/v1/alerts/notable"
tags: ["alerts"]
---

# Notable Alerts

`GET /api/v1/alerts/notable`

## Request

- Base URL: `https://statsmapped.com`
- URL: `https://statsmapped.com/api/v1/alerts/notable`
- Auth: none declared

The most notable recent movement in each series, spread across datasets.

`limit` is capped at 50 (the front end only ever requests 8) rather than left
unbounded — see security-architecture-and-audit.md §4.

`stat_key`, when given, narrows the strip to alerts from ONE stat only (stat-
region-selector-design.md §4) — the front end passes it once a reader has
explicitly picked a stat as the map's active colour-by layer, so a crime-only
reader sees a crime-only strip. Omitted (the default) reproduces today's
behaviour exactly: unfiltered, ranked across every dataset by the same fairness
rule below. This is additive — no existing caller changes behaviour until the
front end starts passing the parameter.

`geography_id`, when given, narrows to series that honestly relate to that
geography (P3, 2026-09-19, the per-county broadsheet fragment) — resolved via
`geo_match_batch()` (databeat/geography.py), the SAME ancestry lookup every other
"does this series count as this area's own" decision on the site already uses,
never re-derived here. `exact`/`aggregate`/`partial` verdicts all count (matching
how the dataset-cards panel already treats them as inclusion-with-caveat, not
exclusion); `none` is excluded. Additive, same as `stat_key` — omitted reproduces
today's nationwide behaviour exactly.

`cluster`, when given, narrows to alerts in ONE topic cluster only (P3 C2,
2026-09-19, "scope-aware headings" — broadsheet-first-vision.md §3: clicking a
section heading on the country broadsheet widens to that topic across every
geography). Bound in the `quota` CTE below, not `eligible` — `cluster` is
ITSELF a computed column there (`coalesce(st.cluster, %(residual)s)`), and
Postgres cannot reference a SELECT-list alias from the same SELECT's own WHERE
clause; `quota` is the first CTE where the computed value is a real selected
column available to filter on. Additive, same shape as `stat_key`/
`geography_id` — a caller that never passes it sees no behaviour change. The
existing per-stat quota (`MAX_ALERTS_PER_STAT`) still applies WITHIN the
filtered cluster, same "no one dataset dominates" discipline the unfiltered
strip already has; the cross-cluster round-robin ordering is a no-op once only
one cluster's rows remain, so nothing needed disabling for it the way the
per-stat quota is suspended for an explicit `stat_key`.

Not simply `/api/alerts` ordered by date: series are published on different
schedules, so a date-ordered list is dominated by whichever dataset published most
recently. Property is monthly and crime quarterly, which meant property's June
alerts filled any reasonable page size and crime's January alerts were never
reached — the crime data was effectively invisible.

DISTINCT ON takes each series' newest alert first, so the ranking happens across
series rather than within one publication date.

THEN THE SAME FAILURE RECURRED ALONG A SECOND AXIS. Ranking the survivors by raw
percentage let whichever dataset *swings hardest* crowd out the rest, exactly as
date-ordering had let whichever dataset published last crowd out the rest. Once PSRA
landed, all 8 slots were crime: divisional crime counts move 40-70 per cent between
quarters as a matter of course, so a county median sale price moving 31 per cent —
extraordinary for property, on 800k transactions — ranked about 17th and never
appeared. Percentages from different units are not comparable, and sorting them into
one list silently asserts that they are.

So rank WITHIN each dataset and interleave: every dataset offers its largest
movement before any dataset offers its second. The strip stays ordered by size
within each round, and no dataset can monopolise it.

AND THEN IT RECURRED A THIRD TIME, ALONG A THIRD AXIS. "Dataset" was defined as
`group_name`, which is per-HOSPITAL for NTPF ("Beaumont Hospital — Outpatient Waiting
List") because Dublin has ~14 hospitals and the name has to distinguish the cards. So
NTPF's 90-odd group_names each contributed a rank-1 alert and swept the first round:
5 of 8 notable alerts were NTPF, four of them the same waiting band. Not because
hospital waits are the biggest story, but because NTPF contributes 360 of 418 series
and therefore wins any count-based contest by construction — a newspaper front page set
by word count. The interleaving was working exactly as designed; the buckets were the
wrong size.

FIXED BY QUOTA, in three nested passes, using the cluster taxonomy (migration 014):

  1. Best alert per group_name, so the two NTPF entries that survive are two different
     hospitals rather than two bands at the same one.
  2. **At most `MAX_ALERTS_PER_STAT` per `stat_key`** — the level a reader would call
     one dataset, and the level at which NTPF is one thing rather than ninety.
  3. Round-robin across the five CLUSTERS: every cluster offers its best before any
     cluster offers its second. The strip therefore leads with one housing, one crime,
     one health, one social and one economy story, which is the product's own editorial
     structure rather than an artefact of which publisher emits the most rows.

The quota is suspended when `stat_key` is passed, because a reader who has explicitly
asked for a crime-only strip wants crime alerts, not two of them.

## Query parameters

- `limit` integer
- `stat_key` string, nullable
- `geography_id` string, nullable
- `cluster` string, nullable

## Response `200`

Successful Response

- NotableAlert[]
  - `country` string
  - `series_key` string, required
  - `display_name` string, required
  - `group_name` string, nullable
  - `rule_type` string, required
  - `period_date` string, required
  - `prev_value` number, required
  - `curr_value` number, required
  - `pct_change` number, required
  - `direction` string, required
  - `geography_id` string, nullable
  - `stat_key` string, nullable
  - `cluster` string, nullable
  - `periodicity` string, nullable
  - `dataset_key` string, nullable
  - `unit` string, nullable
  - `caveats` Caveat[]
    - `country` string
    - `kind` string, required
    - `severity` string, required
    - `label` string, required
    - `body` string, required
    - `source_url` string, nullable
    - `origin` string, required
    - `interpretive` boolean
    - `stats` CaveatStats
      - `country` string
      - `median` number, required
      - `n` integer, required
      - `modified_z` number, required
      - `peer_basis` string, required
  - `series_latest_period` string, nullable
  - `periods_behind` integer, nullable
  - `sample_size` integer, nullable
  - `sample_size_noun` string, nullable

## Other responses

- `400`: Bad request
- `401`: Missing or invalid credentials
- `403`: Not authorized for this request
- `404`: Not found
- `422`: Validation error
- `429`: Rate limited

---

[API](https://skmtc.dev/statsmapped/apis/statsmapped-api.md) · [All operations](https://skmtc.dev/statsmapped/apis/statsmapped-api/llms.txt) · [OpenAPI document](https://skmtc.dev/statsmapped/apis/statsmapped-api/revisions/7c5b264d05b7?raw)
