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.
https://erna.codes/api/public/v1
Pass your key in the X-API-Key header. You can also use Authorization: Bearer … or ?api_key=….
X-API-Key: erna_xxxxxxxxxxxxxxxx
X-API-Key header.GET /cities to discover available city_id values.curl -H "X-API-Key: $ERNA_KEY" \ https://erna.codes/api/public/v1/cities
Content-Type: application/json.next_cursor as ?cursor=… on the next request. When next_cursor is null, there are no more pages.limit default 100, max 1000.since and until accept YYYY-MM-DD or ISO 8601.{ "error": "..." }. 401 = missing/invalid key, 400 = bad parameter, 500 = upstream failure./citiesList every city tracked by ERNA, with coordinates, timezone and primary METAR station.
curl -H "X-API-Key: $ERNA_KEY" \ https://erna.codes/api/public/v1/cities
{
"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"
}
]
}/currentLatest observations for one city from every live source: OpenWeather, Meteoblue, METAR and personal weather stations (PWS).
city_idrequiredcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/current?city_id=tel-aviv"
{
"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": "..." }
}
}/forecastsLatest 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.
city_idrequiredcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/forecasts?city_id=tel-aviv"
{
"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 }
]
}
}
}/historical/{table}Paginated historical rows from a whitelisted table. Sorted by id desc.
city_idoptionalsinceoptionaluntiloptionallimitoptionalcursoroptionalcurl -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"
{
"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 }
]
}/no24NO24 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.
city_idoptionaldateoptionalstatusoptionallimitoptionalcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/no24?status=open"
{
"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"
}
]
}/yescoreYES-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.
city_idoptionaldateoptionalstatusoptionallimitoptionalcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/yescore?status=open"
{
"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"
}
]
}/erna-max-picksERNA 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.
city_idoptionaldateoptionalstatusoptionallimitoptionalcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/erna-max-picks?status=open"
{
"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"
}
]
}/fusion-locksFusion 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`).
city_idoptionalsinceoptionalresolvedoptionallimitoptionalcursoroptionalcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/fusion-locks?city_id=new-york&since=2026-06-20T00:00:00Z"
{
"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
}
]
}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)fetch_locks(limit=500) once without since, then set last_seen to the newest locked_at before entering the loop.locked_at for the cursor, never the client clock.resolved=true and populated actual_tmax_c/hit/pnl_usd. Use id to de-duplicate or update your copy.metar_observations_historyopenweather_historymeteoblue_historypws_observations_historyforecast_historypolymarket_buckets_historykalshi_buckets_historyforecast_accuracy_dailymarket_brief_dailyedge_resolvedmodel_forecasts_dailyopenweather_currentmeteoblue_currentmetar_observationspws_observationserna_pro_forecastedge_ensemble_dailymodel_tmax_range/forecasts returns three forecast layers. Temperatures are stored in °F; the API adds _f / _c aliases so you don't have to convert.
Weighted multi-model ensemble + METAR pacing correction + LLM reasoning. This is the primary ERNA daily Tmax forecast.
predicted_max / predicted_max_f / predicted_max_csigma / sigma_f / sigma_cpacing_deltapacing_sourcehottest_modelmodels_usedhourlyreasoningstation_codelocal_date / fetched_atRaw 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_fsigma / sigma_c / sigma_fmodelsmodels_usedn_modelslocal_dateMin / 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_ctmax_max / tmax_max_f / tmax_max_ctmax_mean / tmax_mean_f / tmax_mean_cspread / spread_fn_modelsmodelsstation_codeconst 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();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"])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:
breakInstead 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.
insurance.pick.createdinsurance.addon.createdinsurance.pick.killededge.trade.enterededge.trade.exitedwebhook.testWe 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.
X-Erna-EventX-Erna-Event-IdX-Erna-SignatureX-Erna-DeliveryUser-AgentSingle 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
}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 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
}
}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
}
}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 "", 2002xx within 8s. Do slow work asynchronously; anything else is treated as a failure and retried.event_id may arrive more than once (retries after transient failures). De-duplicate on event_id.market.bucket_unit — Polymarket runs °F markets for US / UK cities and °C markets for continental EU cities.