Developer documentation

ERNA Weather API

REST API providing programmatic access to live weather observations, AI forecasts (ERNA Pro, model ensembles, tmax range), market briefs and full historical data with cursor pagination.

Base URL
https://erna.codes/api/public/v1
Authentication

Pass your key in the X-API-Key header. You can also use Authorization: Bearer … or ?api_key=….

X-API-Key: erna_xxxxxxxxxxxxxxxx

Quick start

  1. Create a key on the API keys page (key is shown once — copy it immediately).
  2. Send it in the X-API-Key header.
  3. Start with GET /cities to discover available city_id values.
curl -H "X-API-Key: $ERNA_KEY" \
  https://erna.codes/api/public/v1/cities

Conventions

  • Format: All responses are JSON with Content-Type: application/json.
  • Pagination: Cursor-based. Pass the returned next_cursor as ?cursor=… on the next request. When next_cursor is null, there are no more pages.
  • Limits: limit default 100, max 1000.
  • Date filters: since and until accept YYYY-MM-DD or ISO 8601.
  • Errors: Non-2xx responses include { "error": "..." }. 401 = missing/invalid key, 400 = bad parameter, 500 = upstream failure.
  • CORS: Enabled for all origins (GET only).

Endpoints

GET/cities

List every city tracked by ERNA, with coordinates, timezone and primary METAR station.

Request
curl -H "X-API-Key: $ERNA_KEY" \
  https://erna.codes/api/public/v1/cities
Example response
{
  "count": 64,
  "data": [
    {
      "city_id": "tel-aviv",
      "city_name": "Tel Aviv",
      "country": "IL",
      "lat": 32.0853,
      "lon": 34.7818,
      "tz": "Asia/Jerusalem",
      "icao": "LLBG",
      "station_name": "Ben Gurion"
    }
  ]
}
GET/current

Latest observations for one city from every live source: OpenWeather, Meteoblue, METAR and personal weather stations (PWS).

Query parameters
city_idrequired
City identifier returned by /cities.
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/current?city_id=tel-aviv"
Example response
{
  "data": {
    "city_id": "tel-aviv",
    "openweather_current": { "temp_f": 78.4, "fetched_at": "..." },
    "meteoblue_current":   { "temp_f": 79.1, "fetched_at": "..." },
    "metar_observations":  { "temp_f": 78.8, "fetched_at": "..." },
    "pws_observations":    { "temp_f": 79.0, "fetched_at": "..." }
  }
}
GET/forecasts

Latest forecast for one city across all ERNA layers: ERNA Pro (weighted ensemble + pacing correction + reasoning), the edge ensemble, and the per-model tmax range. All temperatures are returned in °F with °C aliases.

Query parameters
city_idrequired
City identifier returned by /cities.
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/forecasts?city_id=tel-aviv"
Example response
{
  "data": {
    "city_id": "tel-aviv",
    "erna_pro_forecast": {
      "city_id": "tel-aviv",
      "local_date": "2026-05-25",
      "station_code": "LLBG",
      "unit": "F",
      "predicted_max": 82.6,        // °F (raw column)
      "predicted_max_f": 82.6,
      "predicted_max_c": 28.11,
      "sigma": 1.4,                 // °F — forecast stdev, feeds probability engine
      "sigma_f": 1.4,
      "sigma_c": 0.78,
      "pacing_delta": 0.6,          // °C — METAR pacing correction applied
      "pacing_source": "metar_pace_v2",
      "hottest_model": "ecmwf_ifs",
      "models_used": [
        { "model": "ecmwf_ifs", "weight": 0.32, "mae": 1.1 },
        { "model": "gfs",       "weight": 0.24, "mae": 1.4 }
      ],
      "hourly": [                   // hourly_unit = "C"
        { "h": "13:00", "t": 27.4 },
        { "h": "14:00", "t": 28.1 }
      ],
      "hourly_unit": "C",
      "reasoning": "Strong ridge over EM, light SW flow...",
      "fetched_at": "2026-05-25T06:12:44Z"
    },
    "edge_ensemble_daily": {
      "city_id": "tel-aviv",
      "local_date": "2026-05-25",
      "unit": "C",
      "fc_mean": 27.94,        // stored °C
      "fc_mean_c": 27.94,
      "fc_mean_f": 82.3,
      "tmax_mean_c": 27.94,
      "tmax_mean_f": 82.3,
      "sigma": 1.2,            // °C
      "sigma_c": 1.2,
      "sigma_f": 2.16,
      "models": [
        { "model": "ecmwf_ifs", "weight": 0.30 },
        { "model": "gfs",       "weight": 0.25 }
      ]
    },
    "model_tmax_range": {
      "city_id": "tel-aviv",
      "local_date": "2026-05-25",
      "station_code": "LLBG",
      "unit": "F",
      "tmax_min": 81.0,  "tmax_min_f": 81.0, "tmax_min_c": 27.22,
      "tmax_max": 83.2,  "tmax_max_f": 83.2, "tmax_max_c": 28.44,
      "tmax_mean": 82.1, "tmax_mean_f": 82.1, "tmax_mean_c": 27.83,
      "spread": 2.2,     "spread_f": 2.2,
      "n_models": 7,
      "models": [
        { "model": "ecmwf_ifs", "tmax": 82.8 },
        { "model": "gfs",       "tmax": 81.4 }
      ]
    }
  }
}
GET/historical/{table}

Paginated historical rows from a whitelisted table. Sorted by id desc.

Query parameters
city_idoptional
Filter by city.
sinceoptional
Inclusive lower bound on timestamp/date column.
untiloptional
Inclusive upper bound on timestamp/date column.
limitoptional
Page size, max 1000 (default 100).
cursoroptional
Pass the previous response's next_cursor.
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/historical/forecast_accuracy_daily?city_id=tel-aviv&since=2026-01-01&limit=500"
Example response
{
  "table": "forecast_accuracy_daily",
  "count": 500,
  "next_cursor": 184213,
  "data": [
    { "id": 184712, "city_id": "tel-aviv", "local_date": "2026-05-24", "abs_error": 1.2 }
  ]
}
GET/no24

NO24 strategy trades. One minute after local midnight the least accurate forecast product (by 30d exact-hit rate vs METAR Tmax) picks a bucket, and NO is bought on it with a flat $10 stake. Newest first.

Query parameters
city_idoptional
Filter by city.
dateoptional
Filter by local market date (YYYY-MM-DD).
statusoptional
`open` for live positions, `resolved` for settled ones.
limitoptional
Page size, max 1000 (default 100).
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/no24?status=open"
Example response
{
  "count": 1,
  "data": [
    {
      "id": 128,
      "city_id": "london",
      "local_date": "2026-08-25",
      "model": "standard",
      "model_exact_pct": 0.0323,
      "predicted_max_c": 24.3,
      "predicted_max_f": 75.74,
      "bucket_slug": "24-25c",
      "bucket_label": "24°C to 25°C",
      "no_ask": 0.72,
      "stake": 10,
      "shares": 13.888889,
      "current_no_price": 0.79,
      "outcome": null,
      "roi": null,
      "side": "NO",
      "strategy": "no24"
    }
  ]
}
GET/yescore

YES-CORE strategy trades. One minute after local midnight the most accurate forecast product (by 30d exact-hit rate vs METAR Tmax) picks a bucket — with a +0.5°C warm-bias correction in °C markets — and YES is bought on it with a flat $10 stake. Newest first.

Query parameters
city_idoptional
Filter by city.
dateoptional
Filter by local market date (YYYY-MM-DD).
statusoptional
`open` for live positions, `resolved` for settled ones.
limitoptional
Page size, max 1000 (default 100).
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/yescore?status=open"
Example response
{
  "count": 1,
  "data": [
    {
      "id": 41,
      "city_id": "paris",
      "local_date": "2026-08-25",
      "model": "erna_pro",
      "model_exact_pct": 0.4516,
      "predicted_max_c": 24.3,
      "corrected_max_c": 24.8,
      "bias_c": 0.5,
      "bucket_slug": "24-25c",
      "bucket_label": "24°C to 25°C",
      "yes_ask": 0.34,
      "edge": 0.11,
      "stake": 10,
      "shares": 29.411765,
      "current_yes_price": 0.41,
      "outcome": null,
      "roi": null,
      "side": "YES",
      "strategy": "yescore"
    }
  ]
}
GET/erna-max-picks

ERNA MAX 11:00 paper trades. At 11:00 local time the Erna Max predictive distribution picks its most probable bucket and YES is bought (paper, flat $10) when the ask sits in 0.50–0.85 and the model probability beats it. Newest first.

Query parameters
city_idoptional
Filter by city.
dateoptional
Filter by local market date (YYYY-MM-DD).
statusoptional
`open` for live positions, `resolved` for settled ones.
limitoptional
Page size, max 1000 (default 100).
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/erna-max-picks?status=open"
Example response
{
  "count": 1,
  "data": [
    {
      "id": 7,
      "city_id": "helsinki",
      "local_date": "2026-08-31",
      "entry_hour": 11,
      "mu_c": 19.4,
      "sigma_c": 0.82,
      "n_models": 6,
      "model_prob": 0.58,
      "bucket_slug": "19c",
      "bucket_label": "19°C",
      "yes_ask": 0.53,
      "edge": 0.05,
      "stake": 10,
      "shares": 18.867925,
      "outcome": null,
      "roi": null,
      "side": "YES",
      "mode": "paper",
      "strategy": "erna_max_11"
    }
  ]
}
GET/fusion-locks

Fusion Forecast peak-hour bucket locks, newest first. Poll incrementally with `since` (ISO timestamp of the last lock you saw) or `cursor` (returned as `next_cursor`).

Query parameters
city_idoptional
Filter by city.
sinceoptional
Only locks with locked_at >= this ISO timestamp.
resolvedoptional
`true` or `false` to filter by resolution status.
limitoptional
Page size, max 500 (default 100).
cursoroptional
Opaque cursor from the previous response's next_cursor.
Request
curl -H "X-API-Key: $ERNA_KEY" \
  "https://erna.codes/api/public/v1/fusion-locks?city_id=new-york&since=2026-06-20T00:00:00Z"
Example response
{
  "count": 2,
  "next_cursor": null,
  "data": [
    {
      "id": "9b1c...",
      "city_id": "new-york",
      "local_date": "2026-06-20",
      "peak_hour": 15,
      "locked_at": "2026-06-20T19:05:12Z",
      "bucket_slug": "28-29c",
      "bucket_label": "28°C to 29°C",
      "event_slug": "highest-temperature-in-nyc-on-...",
      "yes_price": 0.42,
      "tokens": 1.0,
      "resolved": false,
      "actual_tmax_f": null,
      "actual_tmax_c": null,
      "hit": null,
      "payout_usd": null,
      "pnl_usd": null
    }
  ]
}

Polling example — Fusion locks (Python)

The recommended way to consume /fusion-locks is to poll every few seconds and remember the newest locked_at you have already seen. Send it back as since to receive only new or updated locks.

import os
import time
import requests

API_KEY = os.environ["ERNA_API_KEY"]
BASE_URL = "https://erna.codes/api/public/v1"


def fetch_locks(since: str | None = None, city_id: str | None = None, limit: int = 100):
    """Fetch fusion locks newer than `since`."""
    params = {"limit": limit}
    if since:
        params["since"] = since
    if city_id:
        params["city_id"] = city_id

    resp = requests.get(
        f"{BASE_URL}/fusion-locks",
        headers={"X-API-Key": API_KEY},
        params=params,
        timeout=15,
    )
    resp.raise_for_status()
    return resp.json()


def poll_loop(city_id: str | None = None, interval: int = 5):
    """Poll for new locks and print them as they appear."""
    last_seen = None  # ISO timestamp of the newest lock already processed

    while True:
        payload = fetch_locks(since=last_seen, city_id=city_id)
        locks = payload.get("data", [])

        for lock in locks:
            print(
                f"[{lock['locked_at']}] {lock['city_id']} "
                f"peak={lock['peak_hour']} bucket={lock['bucket_label']} "
                f"yes_price={lock['yes_price']} tokens={lock['tokens']}"
            )

            # Remember the newest locked_at across all returned rows
            if last_seen is None or lock["locked_at"] > last_seen:
                last_seen = lock["locked_at"]

        if payload.get("next_cursor"):
            # Rare: more than `limit` rows arrived since the last poll.
            # Follow the cursor immediately to drain the backlog.
            print("More pages available; follow next_cursor in fetch_locks().")

        time.sleep(interval)


if __name__ == "__main__":
    poll_loop(city_id="new-york", interval=5)
  • Backfill on startup: call fetch_locks(limit=500) once without since, then set last_seen to the newest locked_at before entering the loop.
  • Clock drift: use server-returned locked_at for the cursor, never the client clock.
  • Resolutions: a lock may re-appear with resolved=true and populated actual_tmax_c/hit/pnl_usd. Use id to de-duplicate or update your copy.

Available tables

Historical (via /historical/{table})
metar_observations_historyopenweather_historymeteoblue_historypws_observations_historyforecast_historypolymarket_buckets_historykalshi_buckets_historyforecast_accuracy_dailymarket_brief_dailyedge_resolvedmodel_forecasts_daily
Current snapshots (via /current)
openweather_currentmeteoblue_currentmetar_observationspws_observations
Forecasts (via /forecasts)
erna_pro_forecastedge_ensemble_dailymodel_tmax_range

Forecast field reference

/forecasts returns three forecast layers. Temperatures are stored in °F; the API adds _f / _c aliases so you don't have to convert.

erna_pro_forecast

Weighted multi-model ensemble + METAR pacing correction + LLM reasoning. This is the primary ERNA daily Tmax forecast.

predicted_max / predicted_max_f / predicted_max_c
Daily Tmax forecast. Key field.
sigma / sigma_f / sigma_c
Forecast standard deviation (°F / °C). Key input for the probability / pricing engine.
pacing_delta
METAR pacing correction in °C applied to the ensemble. Key for calibration.
pacing_source
Name of the pacing model that produced pacing_delta.
hottest_model
Single model with the highest Tmax in the ensemble. Debug info.
models_used
Array of { model, weight, mae }. Weights drive the ensemble; mae is rolling skill. Key for bias correction.
hourly
Hourly forecast points [{ h, t }] in °C (hourly_unit = "C"). Optional.
reasoning
LLM narrative explaining the forecast.
station_code
Reference METAR station (ICAO). Debug info.
local_date / fetched_at
Forecast target date (local) and produced-at timestamp (UTC).
edge_ensemble_daily

Raw weighted ensemble used by the edge/arbitrage module (no pacing, no LLM). Values are stored in °C; _c/_f aliases are added.

fc_mean / fc_mean_c / fc_mean_f / tmax_mean_c / tmax_mean_f
Ensemble mean Tmax (raw value in °C).
sigma / sigma_c / sigma_f
Ensemble standard deviation (raw in °C).
models
Array of { model, weight } that compose the ensemble (derived from models_used).
models_used
Raw upstream array of model identifiers (strings).
n_models
Number of models in the ensemble.
local_date
Forecast target date (local).
model_tmax_range

Min / max / mean / spread of Tmax across every NWP model, plus a per-model breakdown. Use to evaluate model agreement.

tmax_min / tmax_min_f / tmax_min_c
Lowest Tmax across all models.
tmax_max / tmax_max_f / tmax_max_c
Highest Tmax across all models.
tmax_mean / tmax_mean_f / tmax_mean_c
Unweighted mean across all models.
spread / spread_f
tmax_max − tmax_min (model disagreement).
n_models
Number of models included.
models
Array of { model, tmax } — per-model Tmax forecasts.
station_code
Reference METAR station (ICAO).

Code samples

JavaScript / TypeScript
const res = await fetch(
  "https://erna.codes/api/public/v1/historical/forecast_accuracy_daily?city_id=tel-aviv&limit=1000",
  { headers: { "X-API-Key": process.env.ERNA_KEY! } },
);
const { data, next_cursor } = await res.json();
Python
import os, requests

r = requests.get(
    "https://erna.codes/api/public/v1/forecasts",
    params={"city_id": "tel-aviv"},
    headers={"X-API-Key": os.environ["ERNA_KEY"]},
    timeout=30,
)
r.raise_for_status()
print(r.json()["data"])
Paginate through all rows
cursor = None
while True:
    params = {"city_id": "tel-aviv", "limit": 1000}
    if cursor: params["cursor"] = cursor
    page = requests.get(
        "https://erna.codes/api/public/v1/historical/forecast_accuracy_daily",
        params=params,
        headers={"X-API-Key": os.environ["ERNA_KEY"]},
    ).json()
    for row in page["data"]:
        process(row)
    cursor = page["next_cursor"]
    if cursor is None:
        break

Webhooks — trade signals (Insurance + Edge)

Instead of polling, you can register a webhook URL and receive real-time HTTP POST events every time ERNA opens a new insurance pick, adds a hedge/addon leg, or issues a kill signal. Configure it on the webhooks page.

Event types
insurance.pick.created
Primary pick opened (typically a 2-leg A/B basket around the forecast Tmax).
insurance.addon.created
Hedge/addon leg added to an existing primary pick (single leg C).
insurance.pick.killed
Kill signal — you should exit the position at market.
edge.trade.entered
EDGE engine entered a YES or NO position on a temperature bucket.
edge.trade.exited
EDGE engine exited or settled a YES or NO position.
webhook.test
Manual test event triggered from the webhooks page.
HTTP request

We send POST with Content-Type: application/json and an 8-second timeout. Both http and https are supported (use https in production — http leaks your HMAC secret in transit). Failed deliveries retry with backoff (1m, 5m, 30m, 2h, 12h). After 10 consecutive failures the webhook is disabled automatically.

Headers
X-Erna-Event
Event type, e.g. insurance.pick.created.
X-Erna-Event-Id
UUID of the event. Use as idempotency key — same id may be retried.
X-Erna-Signature
sha256=<hex> HMAC-SHA256 of the raw request body using your webhook secret.
X-Erna-Delivery
Unique delivery id (changes per retry attempt).
User-Agent
Erna-Webhook/1
Payload example — insurance.pick.created

Single schema for all event types; event_type discriminates. Bucket boundaries and the bucket_unit field always match the unit that Polymarket uses for that city (F for US / UK markets, C for continental EU markets). Never convert on the bot side — trust bucket_unit.

{
  "event_id": "b7c3...-uuid",
  "event_type": "insurance.pick.created",
  "event_version": 1,
  "created_at": "2026-07-07T14:00:12Z",
  "attempt": 1,
  "market": {
    "city": "warsaw",
    "market_date": "2026-07-07",
    "market_tz": "Europe/Warsaw",
    "bucket_unit": "C",
    "event_slug": "highest-temperature-in-warsaw-...",
    "polymarket_url": "https://polymarket.com/event/..."
  },
  "trade": {
    "pick_id": "uuid",
    "trade_seq": 3,
    "trade_kind": "primary",
    "parent_pick_id": null,
    "total_cost_usd": 2.34
  },
  "legs": [
    {
      "leg": "A",
      "bucket_slug": "22-23c",
      "bucket_label": "22°C to 23°C",
      "bucket_low": 22, "bucket_high": 23,
      "token_id_yes": "0x...",
      "snapshot_price": 0.42,
      "stake_usd": 1.17,
      "shares": 2.786,
      "max_slippage_bps": 200,
      "side": "YES"
    },
    { "leg": "B", "bucket_label": "23°C to 24°C", "snapshot_price": 0.38, "stake_usd": 1.17, "...": "..." }
  ],
  "kill": null
}
Kill event

For insurance.pick.killed the legs array echoes the original legs and kill tells the bot to exit.

{
  "event_type": "insurance.pick.killed",
  "trade": { "pick_id": "<primary uuid>", "trade_kind": "primary", "...": "..." },
  "legs": [ /* original A + B */ ],
  "kill": { "sell_at_market": true, "reason": "forecast diverged > 2°C" }
}
Edge Forecast events

EDGE engine trade events use their own schema: market plus trade. Headers, HMAC signing and retries are identical to the insurance events.

{
  "event_type": "edge.trade.entered",
  "market": {
    "city": "madrid",
    "market_date": "2026-08-18",
    "event_slug": "highest-temperature-in-madrid-on-august-18-2026",
    "polymarket_url": "https://polymarket.com/event/highest-temperature-in-madrid-on-august-18-2026"
  },
  "trade": {
    "engine_trade_id": 644,
    "operation": "BUY",
    "side": "NO",
    "bucket_label": "40°C",
    "price": 0.846473,
    "qty": 35.441176,
    "total_usd": 30,
    "reason": "ENTER NO: edge=+23.4% our=0.0% mkt=19.0% kelly=3.0% slip=+0.77% lvls=2",
    "executed_at": "2026-08-18T12:11:00.253933+00:00",
    "max_slippage_bps": 200
  }
}
Payload example — edge.trade.exited

Same schema as the entered event; the operation field indicates the exit/settlement action (e.g. SELL or SETTLE) and event_type is edge.trade.exited.

{
  "event_type": "edge.trade.exited",
  "market": {
    "city": "madrid",
    "market_date": "2026-08-18",
    "event_slug": "highest-temperature-in-madrid-on-august-18-2026",
    "polymarket_url": "https://polymarket.com/event/highest-temperature-in-madrid-on-august-18-2026"
  },
  "trade": {
    "engine_trade_id": 644,
    "operation": "SELL",
    "side": "NO",
    "bucket_label": "40°C",
    "price": 0.9520,
    "qty": 35.441176,
    "total_usd": 33.74,
    "reason": "EXIT NO: bucket 40°C resolved false, edge realized +12.5%",
    "executed_at": "2026-08-19T00:03:12.183400+00:00",
    "max_slippage_bps": 200
  }
}
Signature verification (Node / Python)

Verify the HMAC over the raw request body (not the re-serialised JSON) before trusting the payload. Use a timing-safe comparison.

// Node.js / Express
import { createHmac, timingSafeEqual } from "crypto";

app.post("/erna-webhook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = (req.headers["x-erna-signature"] || "").toString();
  const expected = "sha256=" + createHmac("sha256", process.env.ERNA_WEBHOOK_SECRET)
    .update(req.body).digest("hex");
  const a = Buffer.from(sig), b = Buffer.from(expected);
  if (a.length !== b.length || !timingSafeEqual(a, b)) return res.status(401).end();

  const event = JSON.parse(req.body.toString("utf8"));
  // idempotency: skip if you've already processed event.event_id
  handle(event);
  res.status(200).end();
});
# Python / Flask
import hmac, hashlib, os
from flask import request, abort

@app.post("/erna-webhook")
def erna_webhook():
    raw = request.get_data()  # bytes, NOT request.json
    sent = request.headers.get("X-Erna-Signature", "")
    expected = "sha256=" + hmac.new(
        os.environ["ERNA_WEBHOOK_SECRET"].encode(), raw, hashlib.sha256
    ).hexdigest()
    if not hmac.compare_digest(sent, expected):
        abort(401)
    event = request.get_json()
    # idempotency: dedupe on event["event_id"]
    handle(event)
    return "", 200
  • Respond fast: return 2xx within 8s. Do slow work asynchronously; anything else is treated as a failure and retried.
  • Idempotency: the same event_id may arrive more than once (retries after transient failures). De-duplicate on event_id.
  • Units: always trust market.bucket_unit — Polymarket runs °F markets for US / UK cities and °C markets for continental EU cities.
  • SSRF: URLs resolving to private / loopback / link-local IPs are rejected. Use a public URL (a Cloudflare / ngrok tunnel is fine).