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/no1

NO1 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.

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/no1?status=open"
Example response
{
  "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"
    }
  ]
}
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_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

Instead 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.

Event types
edge.trade.entered
EDGE Strategy entered a YES or NO position on a temperature bucket.
edge.trade.exited
EDGE Strategy exited or settled a YES or NO position.
no24.pick.created
NO Strategy (no24) bought a NO token just after local midnight — flat $10 stake.
no24.pick.resolved
NO Strategy position settled (flat $10) — includes outcome, payout and ROI.
no24.pick.created.tiered
Same pick, tiered stake sizing ($5/$10/$15/$20 by the city's prior win rate).
no24.pick.resolved.tiered
Tiered variant settled — payout and ROI use the tiered stake.
no24.pick.created.kelly
Same pick, half-Kelly stake ($5–$25). No event is sent when Kelly shows no edge.
no24.pick.resolved.kelly
Half-Kelly variant settled — payout and ROI use the Kelly stake.
no1.pick.created
NO1 Strategy bought a near-certain NO token (0.98–0.99) far from consensus.
no1.pick.resolved
NO1 Strategy position settled — includes outcome, payout and ROI.
yescore.pick.created
YES Strategy (yescore) bought a YES token just after local midnight.
yescore.pick.resolved
YES Strategy position settled — includes outcome, payout and ROI.
ernamax.forecast.created
MAX Forecast: daily calibrated Erna Max forecast (mu/sigma + bucket probabilities).
ernamax.pick.created
MAX Strategy bought a YES token at the 11:00/15:00 local entry.
ernamax.pick.resolved
MAX Strategy position settled — includes outcome, payout and ROI.
taf.pick.created
TAF Strategy bought a YES token on the bucket from the TAF TX forecast.
taf.pick.resolved
TAF Strategy position settled — includes outcome, payout and ROI.
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. edge.trade.entered.
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
Edge Forecast events

EDGE 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
  }
}
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
  }
}
Strategy pick events (NO / YES / MAX / TAF)

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).

Payload example — no24.pick.created
{
  "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
}
Stake-sizing variants — no24.pick.created.tiered / .kelly

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
}
Per-city ROI filters & meta.no24_city_roi

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
}
Payload example — no24.pick.resolved

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"
  }
}
Payload example — no1.pick.created

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
}
Payload example — yescore.pick.created

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
}
Payload example — ernamax.pick.created

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
}
Payload example — taf.pick.created

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
}
MAX Forecast event (ernamax.forecast.created)

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."
  }
}
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).