# DK Elpris — local backend contract v1

Verified against live sources on **2026-11-11**. This is the implementation contract for the frontend. The supplied research HTML was used only as an index of external links; its layout and product blueprint are not requirements. The backend design below follows the real source schemas and their limitations.

## Run and architecture

```sh
npm start                         # http://127.0.0.1:3210
PORT=3211 npm start               # choose another free local port
node --test                      # offline, deterministic tests
curl 'http://127.0.0.1:3210/api/v1/prices/today?zone=DK1'
```

Node >=22 (verified with 26.4.0), no packages, install step, keys, database or build required. `PORT=0` asks the OS for a free port, printed at startup. Bind is always `127.0.0.1`; a busy explicit port fails instead of exposing another interface. Stop with Ctrl-C. No deployment or publication is configured.

`server/index.js` handles HTTP, validation and static files. `server/prices.js` owns normalization, pricing and windows. `server/time.js` owns Copenhagen calendar boundaries. `server/upstream.js` owns in-memory caches and source-wide backoff. Only the server calls upstream services. The frontend must use relative `/api/v1/...` requests.

Static files are served exclusively from `web/`; the existing root `index.html` is an obsolete prototype and is not served. Until the designer adds `web/index.html`, `/` returns a trivial backend-ready page. `/docs/BACKEND.md` serves this contract. Static paths are realpath-checked against `web/`, including symlinks. The server accepts GET only, validates loopback Host, rejects cross-origin browser requests, and exposes no write or account endpoints. Same-origin frontend usage needs no CORS. Local-only refers to hosting: live prices still require internet, and postcode queries are sent to Elpriser.dk. No full street addresses or personal details are collected.

## Endpoints

All JSON is UTF-8, no key, base `/api/v1`. Responses have `Cache-Control: no-store` so browser caches do not freeze the current interval; upstream caching is separate. Unknown API routes return 404.

| GET endpoint | Parameters and behavior |
|---|---|
| `/health` | Process health and upstream cache/cooldown counters. Does not make upstream requests; `ok` means process alive, not guaranteed source availability. |
| `/prices/today` | `zone=DK1` or `DK2` (default DK1); optional `postcode=2100` **or** `gridArea=791`. Today in Europe/Copenhagen. |
| `/prices/tomorrow` | Same parameters; tomorrow in Copenhagen. Unpublished data is a successful response with `status=not_published`. |
| `/prices` | Same parameters plus `date=YYYY-MM-DD`, default today. Accepted range: last 30 days through tomorrow. Historical full tariffs are normally unavailable because the tariff adapter serves today/tomorrow only. |
| `/cheapest-hours` | Same date/zone/location parameters; `n=3` default, 0.25–24 in steps of 0.25; `kwh=1` default, 0.01–1000; `basis=auto` default, or `spot`, `spotVat`, `full`. |
| `/postcode/2100` | Four digits, no query parameters. Returns source DSO, region, precision, candidates and ambiguity. Unknown/unavailable source lookup becomes 503; never guess a geographic mapping. |
| `/methodology` | Formula, units, exclusions, attribution, link to this contract. |

Price query validation rejects duplicate/unknown parameters, impossible/out-of-range dates, unsupported zones and conflicting location parameters with HTTP 400. `/prices/today` and `/prices/tomorrow` reject `date`; use `/prices` for a selected day. A resolved location in another zone returns **409 `ZONE_MISMATCH`**, with the correct zone in the message. Changing zones in the frontend should clear any incompatible location selection.

## Price response and frontend state

All numeric prices are **DKK/kWh**, including `average` and component values. Multiply by 100 for øre/kWh; round to presentation precision only in the frontend. Negative values are valid. API numbers are rounded to six decimal places; the upstream tariff adapter supplies rounded øre components, so this is not invoice-level precision.

- `spot`: wholesale day-ahead price, excluding VAT and other charges; label “Spot · excl. VAT”.
- `spotVat`: spot × 1.25, without network charges or electricity tax; label “Spot · incl. VAT”.
- `full`: variable household price including network, TSO, electricity tax and VAT; label **“Variable price · incl. VAT”**, with “excludes supplier markup and fixed subscriptions” visible. It is not a complete bill or an exact price for every electricity contract.
- `basis`: `full` only when every returned quarter has usable full-price components; otherwise `spotVat`. Never label this fallback “full price”. Each unavailable `full` is JSON `null`, never zero.
- `fullPrice.available/reason`: distinguish `LOCATION_REQUIRED`, `AMBIGUOUS_POSTCODE`, `TARIFF_DATE_UNAVAILABLE`, `TARIFF_SOURCE_OLD`, or an upstream error code. `place` is the source mapping or null. `separateTsoAndTaxAvailable=false` means the live adapter returns the two charges together, not that the official reference rates are unknown.
- `status`: `available` for a complete day, `partial` for missing intervals, `not_published` for an empty future day, `unavailable` for an empty current/past day. Upstream failure without usable cache is HTTP 503, not `not_published`.
- `complete`, `expectedIntervals`: quality/coverage. A Copenhagen day has 92, 96 or 100 quarters, corresponding to 23, 24 or 25 hours. A partial day can still expose known prices and candidate windows, but must not be presented as the proven daily optimum.
- `intervals`: sorted real quarter-hours; UTC `start` (inclusive), `end` (exclusive), and `localStart` with explicit Copenhagen offset. Stable identity is `start`, **not the displayed hour**, because autumn has two 02:00 hours.
- `hours`: clock-hour means of four consecutive quarters, including both repeated autumn hours. Incomplete hours are omitted; no zero filling. Its values are `spot`, `spotVat`, `full`; detailed components are in `intervals`.
- `current`: the quarter containing server time, or null if outside this selected date or missing. Cache only stores source data; `current` is recomputed for every request. A frontend can update highlighting from interval timestamps without requesting the upstream.
- `summary`: average of known quarter values on `basis`, cheapest and most expensive **rolling one-hour windows**, and their savings for 1 kWh. Rolling windows may begin at :15/:30/:45; do not call them clock-hour minima/maxima. If the data are empty, summaries are null.
- `publication.expectedLocalTime`: “13:00–15:00”, Copenhagen, `guaranteed=false`. This is source guidance, not a forecast or a promise that data appear exactly at 13:00.
- `cache.spot` / `cache.tariff`: independent `fetchedAt`, `ageSeconds`, `stale`, `warning`. Show stale status and actual timestamps. `fullPrice.sourceGeneratedAt` is the tariff publisher timestamp, distinct from fetch time; totals are withheld when that timestamp exceeds one hour. Day-ahead price observation timestamps identify delivery, not fetch freshness.
- `sources`: display source links beside the figures. For tariff-derived data use **“Kilde: Elpriser.dk”** linking to its data page and state that these are this website’s calculations. Preserve Energinet attribution.

Suggested frontend view state: selected zone/date, optional postcode or explicitly selected gridArea, price basis, duration, total kWh, selected interval UTC start, loading/error, and the returned data. Backend does not prescribe visual design. Shareable links can store these selections as query parameters; use an ISO UTC interval identifier to distinguish repeated hours. API `/prices?date=...` can reconstruct a day within the supported 30-day range; older links receive a validation error. There is no notification, forecasting or automated appliance-control API.

## Postcode and full-price method

1. Fetch official `DayAheadPrices`, requesting both DK1/DK2 and today/tomorrow together. `DayAheadPriceDKK` is DKK/MWh; divide by **1000**. `TimeUTC` is parsed as UTC even when the source omits `Z`; do not parse `TimeDK` in the machine timezone. Each current market interval is 15 minutes.
2. Resolve postcode via documented [`Elpriser.dk /data/timepris.json?sted=2100`](https://elpriser.dk/data/timepris.json?sted=2100). Its [OpenAPI](https://elpriser.dk/data/openapi.json) and [data guide](https://elpriser.dk/data/) define `place.region`, `gridArea`, `gridCompany`, `ambiguous`, `candidates`, and precision. Live 2100 resolves to area **791, Radius Elnet A/S, DK2**. No postcode-range heuristics or hand-authored national mapping are used.
3. A postcode can cross DSO boundaries. Elpriser itself chooses the first candidate, but this backend **withholds full price when `ambiguous=true`**. Frontend can show candidate area IDs and ask the user to identify the correct area from their bill, then request `gridArea=<three digits>`. The source `a791` parameter explicitly selects an area. No automatic DK1/N1 or DK2/Radius “representative full price” is shown for users who have not selected a location.
4. Match tariff quarters by absolute start time to the requested day. Use `gridOreInclVat` and `chargesOreInclVat` (combined Energinet fees and electricity tax), each already VAT-inclusive. Do not use the adapter’s independently rounded spot or total to overwrite the official EDS spot price.
5. Calculation (all components DKK/kWh):

```text
spotExVat       = DayAheadPriceDKK / 1000
spotVat         = spotExVat * 1.25
gridExVat       = gridOreInclVat / 100 / 1.25
tsoAndTaxExVat  = chargesOreInclVat / 100 / 1.25
full            = (spotExVat + gridExVat + tsoAndTaxExVat) * 1.25
vat             = (spotExVat + gridExVat + tsoAndTaxExVat) * 0.25
```

Equivalent: `full = spotVat + gridOreInclVat/100 + chargesOreInclVat/100`. This prevents adding VAT twice. Missing tariff quarters stay null. Supplier per-kWh markup, fixed DSO/Energinet/supplier subscriptions, billing fees, rebates and special tax treatment are excluded. Full price is a general private-consumption model. It is unsuitable for production tariffs or business reimbursement calculations.

**2026 national rates, independently checked:** official [DatahubPricelist](https://api.energidataservice.dk/dataset/DatahubPricelist) filtered on `Note=["Elafgift","Systemtarif","Transmissions nettarif"]` returned GLN `5790000432752`, `VATClass=D02`, `ResolutionDuration=P1D`:

| Component | Code | Ex-VAT DKK/kWh | Valid from | Valid to (exclusive) |
|---|---|---:|---|---|
| Transmission | 40000 | 0.043 | 2026-01-01 | 2027-01-01 |
| System | 41000 | 0.072 | 2026-01-01 | 2027-01-01 |
| Electricity tax | EA-001 | 0.008 | 2026-01-01 | 2028-01-01 |

[Skatteministeriet’s Elafgiftsloven table](https://skm.dk/tal-og-metode/satser/satser-og-beloebsgraenser-i-lovgivningen/elafgiftsloven), dated 2026-06-22, separately confirms **0.8 øre/kWh in 2026 and 2027**, temporarily reduced to the EU minimum. The three rates total 0.123 DKK ex VAT, 0.15375 incl. VAT; the live adapter rounds to 15.38 øre. Standard 25% VAT and the component method are documented in [Elpriser’s methodology](https://elpriser.dk/beregningsmetode/#elpris), citing [Momsloven §33](https://www.retsinformation.dk/eli/lta/2025/209). These dated reference values are documented for audit, **not hard-coded as a timeless tariff**. Runtime amounts come from dated matching quarters; the adapter does not expose a separately audited TSO/tax split.

A direct DatahubPricelist sample also contained a **2027 monthly own-producer subscription**, demonstrating why using the first matching DSO record would be wrong. A direct adapter would need explicit GLN + charge code + consumer class, valid-from/to, resolution, discounts and VAT rules. [MTrab’s tariff connector](https://github.com/MTrab/energidataservice/blob/master/custom_components/energidataservice/tariffs/energidataservice/__init__.py) uses such owner-specific selectors and dated tariffs; this backend deliberately uses the documented Elpriser public tariff service instead of maintaining an unverified charge-owner registry.

## Cheapest windows and savings

Enumerate all contiguous windows of `n × 4` quarters within the selected Copenhagen day, including elapsed intervals. Never bridge a missing interval or cross into another day. Use the arithmetic mean: this assumes constant power across the window. In a DST transition, `n` is real elapsed hours. Earliest start wins exact ties. `n` may exceed available data, in which case `cheapest`, `mostExpensive`, `savingsDkk` are null. Requested `basis=full` without complete full-price coverage returns 422; `auto` falls back explicitly to `spotVat`.

`savingsDkk = (mostExpensive.average - cheapest.average) × kwh`. `kwh` is total energy for the whole window, not kW and not kWh per hour. This compares two same-duration alternatives, not a promised annual saving. No appliance consumption, loss or annual-use constants are guessed.

## Caching and failures

- Spot: fresh for 10 minutes, bounded stale-on-error for 24 hours; one today/tomorrow entry contains both zones. Historical days have separate entries. Per-source minimum spacing is 60 seconds, including successful calls, to avoid the observed EDS 429. Switching DK1/DK2 or querying tomorrow reuses the same snapshot.
- Tariffs/postcode: fresh for 5 minutes **per place**, stale-on-error for at most one hour, respecting Elpriser’s documented “no more than every five minutes per address.” A 1-second source-wide gate prevents a burst of different place lookups. Generated source content older than one hour is not used for totals.
- Cold concurrent requests for the same key share one promise. Different keys during source cooldown return 503 (or their own usable stale entry); there is no request fan-out or hidden retry loop.
- A timed-out (15 seconds), malformed, invalid-schema or failed refresh never replaces a known-good cache entry. Entries are bounded to 128 per source and evicted oldest-first. Cache is in memory and resets on process restart; run one instance. No background polling.
- On upstream errors, exponential cooldown starts at 60 seconds and caps at one hour, honoring longer `Retry-After` numeric/date values and EDS “Try again in N seconds” messages. Browser/client retries should wait at least `Retry-After`; never hammer the endpoint.
- Refresh is demand-driven. An expired response waits for one refresh, then returns fresh data or bounded stale data on failure. This is **stale-on-error**, not immediate background stale-while-revalidate.
- Stale data stay tied to their original date/zone/area and are marked in `cache`; never relabel yesterday’s prices as today. Empty successful source results can mean unpublished; a transport failure cannot.
- Error shape: `{ "error": { "code": "...", "message": "...", "retryAfterSeconds": null } }`. 400 validation, 403 local-origin restriction, 404 route/file, 405 method, 409 zone mismatch, 422 explicit unavailable full basis, 503 upstream unavailable/backoff, 500 unexpected internal error. Retryable 503 carries `Retry-After` when known. A tariff-only outage can degrade a successful spot response with `fullPrice.reason`; a spot outage without usable cache returns 503.

## Source decisions and corrections to the index

- [Energi Data Service](https://www.energidataservice.dk/) and [DayAheadPrices API](https://api.energidataservice.dk/dataset/DayAheadPrices): primary no-key spot source. Live schema confirmed `TimeUTC`, `TimeDK`, `PriceArea`, `DayAheadPriceEUR`, `DayAheadPriceDKK`; 15-minute records. The [linked tutorial](https://dev.to/elprisdisplay/how-to-fetch-danish-hourly-electricity-spot-prices-from-energi-data-service-11gd) is useful explanation, not the authoritative numeric source.
- [Elpriser nettarif](https://elpriser.dk/nettarif/): current DSO table explains season/hour variation, fixed subscriptions, and postcode ambiguity. [Data guide](https://elpriser.dk/data/) + [OpenAPI](https://elpriser.dk/data/openapi.json) explicitly authorize no-key JSON reuse under CC BY 4.0 with attribution and frequency limits. Used for mapping and tariffs, not scraped HTML values.
- [DanishGrid cost overview](https://www.danishgrid.com/cost-overview) confirms spot + DSO + TSO + tax structure. Its actual [integration documentation](https://www.danishgrid.com/integration) says REST `/v1` needs an emailed key, MCP needs emailed OAuth credentials, and free REST is limited to one request every 5 seconds. **Free is not anonymous.** No account or email was submitted. Anonymous internal `/api` endpoints are not treated as the supported contract; an inspected TSO response said `source=static`, and postcode 2100 returned no supplier. Not used at runtime.
- [Elprisenligenu API guide](https://www.elprisenligenu.dk/elpris-api): actual documented pattern `/api/v1/prices/YYYY/MM-DD_DK1.json`; no-key prices **exclude VAT/taxes**. Contrary to the index’s provenance claim, its guide says ENTSO-E EUR with exchangeratesapi.io conversion, which can differ from official DKK. The index’s bare `/api/v1/prices` returned 403. No silent cross-provider price substitution is implemented.
- [MTrab/energidataservice](https://github.com/MTrab/energidataservice): reviewed tariff connector and API; separates forecasts from official published prices, owner-specific tariffs and retries. No code copied.
- [Electricity Maps parsers](https://github.com/electricitymaps/electricitymaps-contrib), specifically [DK.py](https://github.com/electricitymaps/electricitymaps-contrib/blob/master/electricitymap/contrib/parsers/DK.py): uses UTC event boundaries, distinct source attribution and explicit forecast type. Its Danish parser covers power exchanges/wind/solar, not household billing; it does not provide postcode tariffs. Frontend map returned 403; parser source was accessible.
- [Carnot](https://www.carnot.dk/) and [EpexPredictor](https://github.com/b3nn0/EpexPredictor): forecasting products, not published settlement prices. EpexPredictor README documents LightGBM, weather/load features, DK1/DK2, and prediction error. MTrab’s Carnot path takes email/key. No forecasts blended into official prices, no forecast service credentials requested.
- [EU Energy](https://euenergy.live/da) and [Elprisenligenu](https://www.elprisenligenu.dk/elpris-api) informed simple zone/date JSON naming. Endpoint names here are independently owned by this backend, not a proxy compatibility promise.

All unique external `href` links in the supplied HTML were opened. Accessible HTML/source bodies were retained outside the repo for research. The remaining links (weather/maps, public-data sites and visual examples) do not determine backend requirements. Reachability audit follows; HTTP 200 can be a JS shell and is not a claim that a data API was verified.

| Link opened | Result |
|---|---|
| https://fonts.googleapis.com | HTTP Error 404: Not Found |
| https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&family=Noto+Sans+SC:wght@400;500;700;900&display=swap | 200 |
| https://earth.nullschool.net/ | 200 |
| https://github.com/cambecc/earth | 200 |
| https://app.electricitymaps.com | HTTP Error 403: Forbidden |
| https://github.com/electricitymaps/electricitymaps-contrib | 200 |
| https://www.elportalen.dk/elpriser/time-for-time | 200 |
| https://www.windy.com/ | 200 |
| https://globe.adsbexchange.com/ | 200 |
| https://www.yr.no/en | 200 |
| https://aursee.org/ | 200 |
| https://airleaf.io/earth?view=forest | 200 |
| https://elprisernu.dk/ | 200 |
| https://www.ventusky.com/ | 200 |
| https://www.gridstatus.io/live/caiso | 200 |
| https://carbonintensity.org.uk/ | 200 |
| https://elprisdisplay.dk | 200 |
| https://dev.to/elprisdisplay/how-to-fetch-danish-hourly-electricity-spot-prices-from-energi-data-service-11gd | 200 |
| https://open-meteo.com/ | 200 |
| https://spot.56k.guru/dk | 200 |
| https://github.com/hexagon/spotweb | 200 |
| https://euenergy.live/da | 200 |
| https://www.danishgrid.com/cost-overview | 200 |
| https://elpriser.dk/nettarif/ | 200 |
| https://www.elprisenligenu.dk/api/v1/prices | HTTP Error 403: Forbidden |
| https://github.com/MTrab/energidataservice | 200 |
| https://github.com/b3nn0/EpexPredictor | 200 |
| https://aqicn.org/map/world/ | 200 |
| https://opensky-network.org/ | HTTP Error 403: Forbidden |
| https://github.com/openskynetwork/opensky-api | 200 |

## Actual local API examples

The following are real responses captured on 2026-11-11, not fixtures. Selected fields are shown where explicitly described; lists shortened to one element are examples of their member shape, not a partial day. Live current time and prices will change.

### GET /api/v1/prices/today?zone=DK1 — selected fields

```json
{
  "date": "2026-11-11",
  "zone": "DK1",
  "status": "available",
  "complete": true,
  "expectedIntervals": 96,
  "basis": "spotVat",
  "current": {
    "start": "2026-11-11T11:30:00.000Z",
    "end": "2026-11-11T11:45:00.000Z",
    "localStart": "2026-11-11T12:30:00+01:00",
    "spot": 2.809068,
    "spotVat": 3.511335,
    "full": null,
    "components": {
      "spotExVat": 2.809068,
      "gridExVat": null,
      "tsoAndTaxExVat": null,
      "vat": null
    }
  },
  "summary": {
    "basis": "spotVat",
    "average": 2.948807,
    "cheapest": {
      "start": "2026-11-11T02:00:00.000Z",
      "end": "2026-11-11T03:00:00.000Z",
      "localStart": "2026-11-11T03:00:00+01:00",
      "average": 1.848011
    },
    "mostExpensive": {
      "start": "2026-11-11T16:15:00.000Z",
      "end": "2026-11-11T17:15:00.000Z",
      "localStart": "2026-11-11T17:15:00+01:00",
      "average": 4.241298
    },
    "savingsDkk": 2.393287
  },
  "cache": {
    "spot": {
      "fetchedAt": "2026-11-11T11:30:30.371Z",
      "ageSeconds": 0,
      "stale": false,
      "warning": null
    },
    "tariff": null
  },
  "generatedAt": "2026-11-11T11:30:30.372Z"
}
```

### GET /api/v1/prices/today?zone=DK2&postcode=2100 — metadata and first interval/hour

```json
{
  "date": "2026-11-11",
  "zone": "DK2",
  "status": "available",
  "basis": "full",
  "fullPrice": {
    "available": true,
    "reason": null,
    "label": "Variable household price including VAT; excluding supplier markup and fixed subscriptions",
    "place": {
      "segment": "2100",
      "query": "zip=2100",
      "region": "DK2",
      "regionLabel": "Østdanmark",
      "gridArea": "791",
      "gridCompany": "Radius Elnet A/S",
      "regionRepresentative": false,
      "ambiguous": false,
      "candidates": [
        "791"
      ],
      "address": null,
      "precision": "postcode"
    },
    "sourceGeneratedAt": "2026-11-11T11:25:20.812Z",
    "separateTsoAndTaxAvailable": false
  },
  "intervals": [
    {
      "start": "2026-11-10T23:00:00.000Z",
      "end": "2026-11-10T23:15:00.000Z",
      "localStart": "2026-11-11T00:00:00+01:00",
      "spot": 1.725822,
      "spotVat": 2.157278,
      "full": 2.443778,
      "components": {
        "spotExVat": 1.725822,
        "gridExVat": 0.10616,
        "tsoAndTaxExVat": 0.12304,
        "vat": 0.488756
      }
    }
  ],
  "hours": [
    {
      "start": "2026-11-10T23:00:00.000Z",
      "end": "2026-11-11T00:00:00.000Z",
      "localStart": "2026-11-11T00:00:00+01:00",
      "spot": 1.622906,
      "spotVat": 2.028633,
      "full": 2.315133
    }
  ]
}
```

### GET /api/v1/postcode/2100 — complete response

```json
{
  "postcode": "2100",
  "segment": "2100",
  "query": "zip=2100",
  "region": "DK2",
  "regionLabel": "Østdanmark",
  "gridArea": "791",
  "gridCompany": "Radius Elnet A/S",
  "regionRepresentative": false,
  "ambiguous": false,
  "candidates": [
    "791"
  ],
  "address": null,
  "precision": "postcode",
  "status": "resolved",
  "source": {
    "name": "Elpriser.dk",
    "url": "https://elpriser.dk/data/",
    "license": "CC BY 4.0",
    "role": "postcode and grid tariff / TSO + electricity tax",
    "attribution": "Kilde: Elpriser.dk. Calculations by this website."
  },
  "cache": {
    "fetchedAt": "2026-11-11T11:30:30.489Z",
    "ageSeconds": 0,
    "stale": false,
    "warning": null
  },
  "sourceGeneratedAt": "2026-11-11T11:25:20.812Z"
}
```

### GET /api/v1/cheapest-hours?zone=DK1&n=4&kwh=10 — complete response

```json
{
  "date": "2026-11-11",
  "zone": "DK1",
  "timezone": "Europe/Copenhagen",
  "unit": "DKK/kWh",
  "status": "available",
  "complete": true,
  "basis": "spotVat",
  "n": 4,
  "kwh": 10,
  "cheapest": {
    "start": "2026-11-11T00:45:00.000Z",
    "end": "2026-11-11T04:45:00.000Z",
    "localStart": "2026-11-11T01:45:00+01:00",
    "average": 1.875938
  },
  "mostExpensive": {
    "start": "2026-11-11T15:00:00.000Z",
    "end": "2026-11-11T19:00:00.000Z",
    "localStart": "2026-11-11T16:00:00+01:00",
    "average": 3.953891
  },
  "savingsDkk": 20.77953,
  "scope": "Selected calendar day; includes elapsed intervals; starts on quarter-hour boundaries.",
  "assumption": "Total kWh spread uniformly through either window. Savings compare the cheapest and most expensive windows of equal duration.",
  "cache": {
    "spot": {
      "fetchedAt": "2026-11-11T11:30:30.371Z",
      "ageSeconds": 0,
      "stale": false,
      "warning": null
    },
    "tariff": null
  },
  "sources": [
    {
      "name": "Energinet · Energi Data Service",
      "url": "https://www.energidataservice.dk/tso-electricity/DayAheadPrices",
      "license": "CC BY 4.0",
      "role": "spot"
    }
  ]
}
```

### GET /api/v1/prices/tomorrow?zone=DK1 — selected fields

```json
{
  "date": "2026-11-12",
  "zone": "DK1",
  "status": "not_published",
  "complete": false,
  "expectedIntervals": 96,
  "current": null,
  "publication": {
    "expectedLocalTime": "13:00–15:00",
    "timezone": "Europe/Copenhagen",
    "guaranteed": false
  },
  "intervals": []
}
```

### GET /api/v1/health — complete response

```json
{
  "status": "ok",
  "localOnly": true,
  "timezone": "Europe/Copenhagen",
  "now": "2026-11-11T11:30:30.175Z",
  "upstream": {
    "spot": {
      "cachedEntries": 0,
      "inFlight": 0,
      "retryAfterSeconds": 0,
      "consecutiveFailures": 0
    },
    "tariff": {
      "cachedEntries": 0,
      "inFlight": 0,
      "retryAfterSeconds": 0,
      "consecutiveFailures": 0
    }
  }
}
```

## Verification and limits

`node --test` passes 10 tests: conversions/negative values, missing and duplicate data, full-price/VAT math, postcode ambiguity and zone mismatch, Copenhagen spring/autumn DST, consecutive windows and ties, not-published state, concurrent cache deduplication, bounded stale/429 backoff, schema rejection and HTTP validation. Live HTTP checks returned 200 for health, both zones (96 quarters each), tomorrow (not published at capture), cheapest windows, postcode 2100, methodology and full prices (96 valid full-price quarters).

Remaining limits: third-party tariff availability and mapping coverage; rounded combined TSO/tax source components; no independently maintained DSO-code registry; no historical full-price tariff archive; no persistent cache across process restarts; only one local process should run. Postcode ambiguity is safely withheld but only a non-ambiguous postcode was verified live; ambiguous behavior is tested with controlled data. Official 2026 reference rates require rechecking for another year. No licensed third-party implementation was copied, and no blanket license is asserted for unrelated research sites.
