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"
}
]
}/no1NO1 strategy trades. Once an hour we look for buckets the market already prices as near-impossible (NO ask 0.975–0.995) that also sit at least 3°C and 3 ladder steps away from the model consensus (Erna Pro / MAX Forecast / Fusion), away from the most expensive YES bucket, and unreachable given live METAR Tmax and the TAF TX forecast. Flat $5 stake, max 2 positions per city per local day. Newest first.
city_idoptionaldateoptionalstatusoptionallimitoptionalcurl -H "X-API-Key: $ERNA_KEY" \ "https://erna.codes/api/public/v1/no1?status=open"
{
"count": 1,
"data": [
{
"id": 41,
"city_id": "warsaw",
"local_date": "2026-09-04",
"local_hour": 13,
"bucket_slug": "12c",
"bucket_label": "12°C",
"no_ask": 0.99,
"stake": 5,
"shares": 5.050505,
"consensus_c": 21.4,
"consensus_n": 4,
"distance_c": 8.4,
"bucket_gap": 8,
"top_yes_label": "21°C",
"top_yes_price": 0.54,
"taf_risk": "calm",
"metar_tmax_so_far_c": 20.6,
"current_no_price": 0.99,
"outcome": null,
"roi": null,
"side": "NO",
"strategy": "no1"
}
]
}/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_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 a strategy enters or exits a trade. Configure it on the webhooks page.
edge.trade.enterededge.trade.exitedno24.pick.createdno24.pick.resolvedno24.pick.created.tieredno24.pick.resolved.tieredno24.pick.created.kellyno24.pick.resolved.kellyno1.pick.createdno1.pick.resolvedyescore.pick.createdyescore.pick.resolvedernamax.forecast.createdernamax.pick.createdernamax.pick.resolvedtaf.pick.createdtaf.pick.resolvedwebhook.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-AgentEDGE engine trade events use their own schema: market plus trade. Bucket boundaries always match the unit Polymarket uses for that city — never convert on the bot side.
{
"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
}
}All strategy pick events share one envelope: event_id, event_type, event_version, created_at, attempt, market, trade and result. result is null on *.pick.created and filled on *.pick.resolved. Temperatures in *_c fields are always Celsius; bucket_low/bucket_high/bucket_label use the unit Polymarket uses for that city (market.bucket_unit).
{
"event_id": "3f1c9e6e-2b8a-4a1f-9d55-5a2d0f8c1a11",
"event_type": "no24.pick.created",
"event_version": 1,
"created_at": "2026-09-01T22:10:04.512Z",
"attempt": 1,
"market": {
"city": "warsaw",
"market_date": "2026-09-02",
"market_tz": "Europe/Warsaw",
"bucket_unit": "C",
"event_slug": "highest-temperature-in-warsaw-on-september-2-2026",
"polymarket_url": "https://polymarket.com/event/highest-temperature-in-warsaw-on-september-2-2026"
},
"trade": {
"pick_id": 812,
"strategy": "no24",
"staking": "flat",
"side": "NO",
"model": "erna_pro",
"model_exact_pct": 12.5,
"predicted_max_c": 24.3,
"bucket_slug": "24c",
"bucket_label": "24°C",
"bucket_low": 24,
"bucket_high": 24,
"no_ask": 0.72,
"stake_usd": 10,
"shares": 13.888889,
"max_slippage_bps": 200
},
"result": null
}The same NO pick can be delivered under three stake-sizing rules. Subscribe to whichever you want your bot to trade: no24.pick.created (flat $10), no24.pick.created.tiered ($5/$10/$15/$20 by the city's prior win rate) or no24.pick.created.kelly (half-Kelly, $5–$25). Win rates only use days strictly before the traded day. Envelope and fields are identical; only trade.staking, trade.stake_usd and trade.shares differ. When half-Kelly finds no edge (or the optimal stake falls under the $5 minimum), no Kelly event is sent at all — the flat event still fires.
{
"event_type": "no24.pick.created.kelly",
"trade": {
"pick_id": 812,
"strategy": "no24",
"staking": "kelly",
"side": "NO",
"no_ask": 0.72,
"stake_usd": 18,
"shares": 25
},
"result": null
}In webhook settings you can set a minimum historical ROI per variant (flat / tiered / kelly, default −50%). If a city's full-history ROI for that variant is below your threshold, the no24.pick.created* event for that city and variant is not delivered. The filter never applies tono24.pick.resolved*: settlement events are always delivered, so a bot that received an entry signal always learns how the position closed. Every NO24 payload also carries meta.no24_city_roi with the city's historical ROI per variant (null when there is no settled history yet), so your bot can apply its own filtering too.
{
"event_type": "no24.pick.created.kelly",
"trade": { "pick_id": 812, "strategy": "no24", "staking": "kelly", "side": "NO", "stake_usd": 18, "shares": 25 },
"meta": {
"no24_city_roi": { "flat": 0.0574, "tiered": 0.0697, "kelly": -0.32 }
},
"result": null
}Same envelope; result carries the settlement. no24.pick.resolved.tiered and no24.pick.resolved.kelly repeat it with that variant's stake, payout and ROI. yescore.pick.resolved, ernamax.pick.resolved and taf.pick.resolved use the identical result block.
{
"event_type": "no24.pick.resolved",
"trade": { "pick_id": 812, "strategy": "no24", "staking": "flat", "side": "NO", "stake_usd": 10, "no_ask": 0.72 },
"result": {
"outcome": "win",
"actual_tmax_c": 26.1,
"payout_usd": 13.888889,
"roi": 0.3889,
"resolved_at": "2026-09-03T01:12:44.180Z"
}
}NO1 adds the safety fields it used to accept the trade: consensus_c, consensus_sources, distance_c, bucket_gap, top_yes_label, top_yes_price, taf_risk and metar_tmax_so_far_c. no1.pick.resolved uses the same result block as no24.pick.resolved.
{
"event_id": "9a3d0e11-77bc-4c02-9f4a-6de2b1c7a005",
"event_type": "no1.pick.created",
"event_version": 1,
"created_at": "2026-09-04T11:38:02.104Z",
"attempt": 1,
"market": {
"city": "warsaw",
"market_date": "2026-09-04",
"market_tz": "Europe/Warsaw",
"bucket_unit": "C",
"event_slug": "highest-temperature-in-warsaw-on-september-4-2026",
"polymarket_url": "https://polymarket.com/event/highest-temperature-in-warsaw-on-september-4-2026"
},
"trade": {
"pick_id": 41,
"strategy": "no1",
"side": "NO",
"bucket_slug": "12c",
"bucket_label": "12°C",
"bucket_low": 12,
"bucket_high": 13,
"no_ask": 0.99,
"yes_at_entry": 0.01,
"stake_usd": 5,
"shares": 5.050505,
"consensus_c": 21.4,
"consensus_sources": 4,
"distance_c": 8.4,
"bucket_gap": 8,
"top_yes_label": "21°C",
"top_yes_price": 0.54,
"taf_risk": "calm",
"metar_tmax_so_far_c": 20.6,
"max_slippage_bps": 100
},
"result": null
}YES Strategy adds the per-city bias correction fields corrected_max_c, bias_c and edge.
{
"event_type": "yescore.pick.created",
"market": { "city": "madrid", "market_date": "2026-09-02", "market_tz": "Europe/Madrid", "bucket_unit": "C" },
"trade": {
"pick_id": 511,
"strategy": "yescore",
"side": "YES",
"model": "standard",
"model_exact_pct": 31.2,
"predicted_max_c": 32.4,
"corrected_max_c": 33.1,
"bias_c": 0.7,
"bucket_slug": "33c",
"bucket_label": "33°C",
"bucket_low": 33,
"bucket_high": 33,
"yes_ask": 0.34,
"edge": 0.08,
"stake_usd": 10,
"shares": 29.411765,
"max_slippage_bps": 200
},
"result": null
}MAX Strategy is paper-traded (mode: "paper") and reports the local entry_hour (11 or 15) plus the Erna Max distribution used.
{
"event_type": "ernamax.pick.created",
"market": { "city": "toronto", "market_date": "2026-09-02", "market_tz": "America/Toronto", "bucket_unit": "C" },
"trade": {
"pick_id": 77,
"strategy": "erna_max_11",
"side": "YES",
"mode": "paper",
"entry_hour": 11,
"mu_c": 25.8,
"sigma_c": 1.1,
"n_models": 6,
"model_prob": 0.41,
"bucket_slug": "26c",
"bucket_label": "26°C",
"bucket_low": 26,
"bucket_high": 26,
"yes_ask": 0.62,
"edge": 0.09,
"stake_usd": 10,
"shares": 16.129032,
"max_slippage_bps": 200
},
"result": null
}TAF Strategy fires once per distinct TX value in the day's TAF bulletins. leg is the escalation step (1 = $5, 2 = $10, 3 = $20, …) and taf_issue_time/taf_tx_at identify the bulletin used.
{
"event_type": "taf.pick.created",
"market": { "city": "beijing", "market_date": "2026-09-02", "market_tz": "Asia/Shanghai", "bucket_unit": "C" },
"trade": {
"pick_id": 34,
"strategy": "taf",
"side": "YES",
"leg": 2,
"taf_issue_time": "2026-09-02T05:00:00+00:00",
"taf_tx_c": 31,
"taf_tx_at": "2026-09-02T07:00:00+00:00",
"bucket_slug": "31c",
"bucket_label": "31°C",
"bucket_low": 31,
"bucket_high": 31,
"yes_ask": 0.28,
"stake_usd": 10,
"shares": 35.714286,
"max_slippage_bps": 200
},
"result": null
}Not a trade: this is the daily calibrated Erna Max forecast for a city, with the full bucket probability distribution. Use it to build your own sizing logic.
{
"event_id": "9a4f77c1-0f2e-4c33-8f10-9d1a1c77e001",
"event_type": "ernamax.forecast.created",
"event_version": 1,
"created_at": "2026-09-02T09:05:11.004Z",
"attempt": 1,
"market": {
"city": "warsaw",
"market_date": "2026-09-02",
"market_tz": "Europe/Warsaw",
"bucket_unit": "C"
},
"forecast": {
"model": "erna_max",
"version": "1.2",
"mu_c": 24.6,
"sigma_c": 1.05,
"mu_models_c": 24.1,
"pacing_weight": 0.45,
"pacing_source": "metar",
"n_models": 7,
"top_bucket_slug": "25c",
"top_bucket_label": "25°C",
"top_bucket_prob": 0.38,
"top_bucket_market_yes": 0.29,
"top_bucket_edge": 0.09,
"bucket_probs": [
{ "slug": "23c", "label": "23°C", "prob": 0.11 },
{ "slug": "24c", "label": "24°C", "prob": 0.29 },
{ "slug": "25c", "label": "25°C", "prob": 0.38 },
{ "slug": "26c", "label": "26°C", "prob": 0.17 }
],
"reasoning": "METAR pacing above climatology; 30d bias +0.4C applied."
}
}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.