Language

ioBroker · price and CO2

Use electricity price and CO2 in ioBroker without building a custom adapter

If you already use the ioBroker JavaScript adapter, one script is enough. It polls the API every 30 minutes by default and writes simple states under 0_userdata.0.

Core logic explained early

The price logic is not “forecast only”. It deliberately works in two steps so that real market data wins whenever it exists.

Step 1As soon as official day-ahead exists for a slot, that value is preferred.
Step 2Forecast is used only for future slots outside the already available day-ahead coverage.
Why this mattersThe API therefore combines real day-ahead prices with forecast values only for the still-open part of the horizon.
The pragmatic path

For most setups, a small JavaScript script is more useful than a dedicated adapter. You see real states immediately, can visualize them, and can reuse the same data in your own automation logic later.

Requirement

You need the JavaScript adapter in ioBroker. The example uses the adapter's built-in axios plus standard functions such as createState and setState.

Recommended endpoint

ioBroker has its own summary path. It returns the compact fields that are usually enough for first automations, plus meta fields for limits and API key status.

https://api.energypriceforecast.eu/api/v1/iobroker/summary?country=de&hours=48&window_hours=4
Directly usablePrice, CO2 and window data arrive as simple JSON.
Own statesThe script writes values under 0_userdata.0.energypriceforecast.
Meta includedapi_key_state, allowed horizon and daily counters are written as states as well.

Alternative for custom price logic

If you do not want to work with the compact summary, there is also a second ioBroker path that returns only the price series. That path is meant for more advanced users who want to build their own window logic, charts, or pricing rules in JavaScript.

https://api.energypriceforecast.eu/api/v1/iobroker/prices?country=de&hours=48&resolution=15m
summaryBeginner path with current price, CO2 and already computed windows.
pricesRaw price series for your own scripts, charts and automation logic.
Important honestyDay-ahead stays native 15-minute. In 15m mode, forecast is not smoothed but simply repeated across four quarter-hour slots.

What the API actually returns

The endpoint returns a general automation summary. That is useful for ioBroker because you are not locked into one special adapter and can reuse the same data in scripts, dashboards or custom logic.

Part Content Why it matters
flatCurrent price, current CO2, boolean fields for active best windows and remaining minutes.Ideal for simple states.
priceCurrent price slot plus best_window and next_full_window as objects.More context for your own logic.
co2Current CO2 slot plus best_window and next_full_window.For CO2-aware automations.
sourceMetadata about day-ahead and forecast data.Useful for debugging and plausibility checks.
metaAccess and contract information such as allowed horizon, API key status and daily usage.Useful for tracking by user or integration.

If you need a real price series for your own calculations instead of the compact summary, use the separate /api/v1/iobroker/prices path. The summary intentionally stays on compact base-price data. The price-series path also returns base prices by default, but can now optionally return assumption-based retail totals.

Price series for your own scripts: iobroker/prices

The extra price-series path intentionally returns prices only, without CO2 fields and without precomputed window logic. That is exactly the point: you can decide inside ioBroker how you want to compute cheap windows, thresholds, or visualizations.

Parameter Meaning Typical use
mode=mixedDay-ahead first, forecast only for future hours not yet covered by official day-ahead data.Practical default.
mode=forecast_onlyForecast only, without day-ahead mixing.Comparison, debugging, or intentionally forecast-only logic.
price_mode=baseDefault mode with base or market price values.When the relative shape is enough for your automation.
price_mode=retailAssumption-based total retail price with markups, grid fees and VAT for supported markets.When you want to automate closer to an end-customer price.
plz=10115Required for Germany in retail mode so grid-fee assumptions can match the area.Mandatory for price_mode=retail in Germany.
resolution=15mContinuous quarter-hour series. Forecast hours are simply repeated across four quarter-hour slots.Charts and uniform slot logic.
resolution=nativeDay-ahead stays quarter-hourly, forecast stays hourly.When you want to preserve the original source resolution.
https://api.energypriceforecast.eu/api/v1/iobroker/prices?country=de&hours=48&mode=mixed&resolution=15m
https://api.energypriceforecast.eu/api/v1/iobroker/prices?country=de&hours=48&mode=mixed&resolution=15m&price_mode=retail&plz=10115

Important: in 15m mode, forecast values are not smoothed between two hours. The API does not invent intermediate prices. Instead, it clearly marks that these quarter-hour slots come from one hourly forecast block.

Base price or total retail price?

The ioBroker summary path still deliberately returns base or market prices. The prices path also returns base prices by default, but can now optionally output assumption-based retail totals.

summaryStays compact and remains on base prices.
prices with price_mode=retailCan return an optional 15-minute or native series as an assumption-based total retail price.
Important honestyRetail is not your exact bill amount. It is a modeled end price based on assumptions. In Germany, retail mode requires a postcode. Supported markets are currently DE, NL, DK1, DK2, AT and NO1 to NO5.

Time horizon and resolution

The example uses hours=48 and window_hours=4. That is a sensible starting point, but not the only possible setup.

Time horizonUse hours to define how far ahead the request should look. Publicly, we currently communicate up to 120 hours for the price forecast.
Actually allowedWithout an API key, 48 hours are currently free. A key may allow more, but more than 120 hours should not be advertised as a reliable public promise right now. The authoritative field is always meta.allowed_horizon_hours.
ResolutionDay-ahead can arrive in quarter-hour slots, while forecast and CO2 are usually hourly.

Important: source.price.day_ahead_entries and source.price.forecast_entries count slots, not hours. That is why values such as 97 day-ahead and 0 forecast can be perfectly valid.

Quick start for copy and paste

  1. Open the JavaScript adapter in ioBroker Admin.
  2. Create a new JavaScript script.
  3. Paste the complete block below and save it.
  4. Run it once manually or wait briefly. New states should appear under 0_userdata.0.energypriceforecast.
Built for beginnersThe script creates the required states by itself. You do not need to prepare data points manually first.
What you usually changeNormally only COUNTRY and optionally API_KEY.
What you should see afterwardsCurrent price, current CO2, active best windows, next full future windows and the most important API meta fields.

Ready-to-use script for the JavaScript adapter

The script fetches data immediately at startup and then every 30 minutes by default. That is a sensible default for many setups: day-ahead data does not change constantly, but the current slot, active windows and forecast context still move forward. If you do not have an API key, just leave API_KEY empty.

const axios = require("axios");

const COUNTRY = "de";
const API_KEY = "";
const API_URL = "https://api.energypriceforecast.eu/api/v1/iobroker/summary?country=" + COUNTRY + "&hours=48&window_hours=4";
const ROOT = "0_userdata.0.energypriceforecast";
const POLL_MS = 30 * 60 * 1000;

const STATE_DEFINITIONS = [
  [".current_price", 0, { name: "Current price", type: "number", role: "value", unit: "EUR/kWh", read: true, write: false }],
  [".current_co2_g_kwh", 0, { name: "Current CO2 intensity", type: "number", role: "value", unit: "gCO2/kWh", read: true, write: false }],
  [".cheapest_window_start", "", { name: "Cheapest window start", type: "string", role: "text", read: true, write: false }],
  [".greenest_window_start", "", { name: "Greenest window start", type: "string", role: "text", read: true, write: false }],
  [".is_cheapest_window_now", false, { name: "Best price window active now", type: "boolean", role: "indicator", read: true, write: false }],
  [".cheapest_window_remaining_minutes", 0, { name: "Best price window remaining minutes", type: "number", role: "value.interval", unit: "min", read: true, write: false }],
  [".next_full_cheapest_window_start", "", { name: "Next full price window", type: "string", role: "text", read: true, write: false }],
  [".is_greenest_window_now", false, { name: "Best CO2 window active now", type: "boolean", role: "indicator", read: true, write: false }],
  [".api_key_state", "missing", { name: "API key state", type: "string", role: "text", read: true, write: false }],
  [".allowed_horizon_hours", 0, { name: "Allowed horizon", type: "number", role: "value", unit: "h", read: true, write: false }],
  [".used_horizon_hours", 0, { name: "Used horizon", type: "number", role: "value", unit: "h", read: true, write: false }],
  [".rate_limit_daily", 0, { name: "Daily limit", type: "number", role: "value", read: true, write: false }],
  [".used_calls_today", 0, { name: "Used calls today", type: "number", role: "value", read: true, write: false }],
  [".last_update", "", { name: "Last update", type: "string", role: "text", read: true, write: false }],
  [".last_error", "", { name: "Last error", type: "string", role: "text", read: true, write: false }]
];

STATE_DEFINITIONS.forEach(([suffix, initialValue, definition]) => {
  createState(ROOT + suffix, initialValue, definition);
});

function setTextState(suffix, value) {
  setState(ROOT + suffix, value == null ? "" : String(value), true);
}

function setNumberState(suffix, value, digits) {
  if (!Number.isFinite(value)) {
    return;
  }

  const normalized = typeof digits === "number" ? Number(value.toFixed(digits)) : Number(value);
  setState(ROOT + suffix, normalized, true);
}

function setBooleanState(suffix, value) {
  if (typeof value !== "boolean") {
    return;
  }

  setState(ROOT + suffix, value, true);
}

async function updateEnergyPriceForecast() {
  const headers = {};

  if (API_KEY.trim()) {
    headers.Authorization = "Bearer " + API_KEY.trim();
  }

  try {
    const response = await axios.get(API_URL, {
      timeout: 10000,
      headers
    });

    const data = response.data || {};
    const flat = data.flat || {};
    const meta = data.meta || {};

    setNumberState(".current_price", flat.current_price, 4);
    setNumberState(".current_co2_g_kwh", flat.current_co2_g_kwh, 1);
    setTextState(".cheapest_window_start", flat.cheapest_window_start);
    setTextState(".greenest_window_start", flat.greenest_window_start);
    setBooleanState(".is_cheapest_window_now", flat.is_cheapest_window_now);
    setNumberState(".cheapest_window_remaining_minutes", flat.cheapest_window_remaining_minutes);
    setTextState(".next_full_cheapest_window_start", data.price && data.price.next_full_window ? data.price.next_full_window.start : null);
    setBooleanState(".is_greenest_window_now", flat.is_greenest_window_now);
    setTextState(".api_key_state", meta.api_key_state || (API_KEY.trim() ? "unknown" : "missing"));
    setNumberState(".allowed_horizon_hours", meta.allowed_horizon_hours);
    setNumberState(".used_horizon_hours", meta.used_horizon_hours);
    setNumberState(".rate_limit_daily", meta.rate_limit_daily);
    setNumberState(".used_calls_today", meta.used_calls_today);
    setTextState(".last_update", data.generated_at);
    setTextState(".last_error", "");
  } catch (error) {
    const statusCode = error && error.response ? error.response.status : null;
    const errorData = error && error.response ? error.response.data || {} : {};
    const errorMeta = errorData.meta || {};

    setTextState(".api_key_state", errorMeta.api_key_state || (API_KEY.trim() ? "error" : "missing"));

    if (statusCode === 401) {
      setTextState(".last_error", "401: API key missing, invalid, or disabled.");
    } else if (statusCode === 403) {
      setTextState(".last_error", "403: Access is not allowed for this route, market, or contract state.");
    } else if (statusCode === 429) {
      setTextState(".last_error", "429: Daily limit reached.");
    } else {
      setTextState(".last_error", "Request failed.");
    }

    console.error("EnergyPriceForecast error:", statusCode, error && error.message ? error.message : error);
  }
}

updateEnergyPriceForecast();
setInterval(updateEnergyPriceForecast, POLL_MS);

If you want another market, normally only COUNTRY changes. For Denmark, always use dk1 or dk2. If you intentionally want more frequent polling, change POLL_MS yourself. The default is deliberately conservative.

Where to put the API key

The key goes directly into the API_KEY variable. If you have received a test key, you can also raise the requested horizon in the URL, for example to 120 hours.

const API_KEY = "YOUR_TEST_KEY";
const API_URL = "https://api.energypriceforecast.eu/api/v1/iobroker/summary?country=de&hours=120&window_hours=4";

Important: the final authority is not only the URL, but meta.allowed_horizon_hours. That field tells you what your current access is actually allowed to use server-side.

For automations: separate "run now" from "plan later"

A common mistake is to look only at cheapest_window_start. That is enough for display, but often too rough for real control. With is_cheapest_window_now, you can directly detect whether the best price window is already active. With next_full_cheapest_window_start, you can plan the next full future window.

on({ id: ROOT + ".is_cheapest_window_now", change: "any" }, obj => {
  if (obj.state.val === true) {
    log("Best price window is active now.");
    // enable the wallbox or start EV charging
  }
});

Pragmatically speaking: for EV charging, heat pumps, or batteries, a boolean for "now" is more robust than comparing one future timestamp.

Which states the script creates

State Meaning Typical use
0_userdata.0.energypriceforecast.current_priceCurrent price in EUR/kWh.Threshold logic or visualization.
0_userdata.0.energypriceforecast.current_co2_g_kwhCurrent CO2 intensity.CO2-aware automations.
0_userdata.0.energypriceforecast.cheapest_window_startStart time of the cheapest future window.Later charging or heating triggers.
0_userdata.0.energypriceforecast.greenest_window_startStart time of the lowest-CO2 future window.CO2-oriented control.
0_userdata.0.energypriceforecast.is_cheapest_window_nowtrue if the best price window is already active.Direct run/allow logic without your own time comparisons.
0_userdata.0.energypriceforecast.cheapest_window_remaining_minutesRemaining minutes of the currently best price window.Keep devices running only while the window is still active.
0_userdata.0.energypriceforecast.next_full_cheapest_window_startStart time of the next full future price window.Planning instead of immediate decisions.
0_userdata.0.energypriceforecast.api_key_stateStatus of the API key in use.Troubleshooting and checking the key state.
0_userdata.0.energypriceforecast.allowed_horizon_hoursServer-side maximum horizon currently allowed.Check whether a key unlocks more.
0_userdata.0.energypriceforecast.used_calls_todayRequests already used today.Keep an eye on limits or monitoring.
0_userdata.0.energypriceforecast.last_errorLast request-related error message.Troubleshooting without jumping directly into adapter logs.

Errors and limits

What does 401 mean?

The API key is missing, wrong, or disabled. The script writes both api_key_state and a readable error message for exactly that reason.

What does 403 mean?

The request is technically valid, but not allowed for this access mode. That is usually about route, market, or contract state rather than a simple typo.

What does 429 mean?

The daily limit has been reached. That is exactly why rate_limit_daily and used_calls_today exist.

How often should I poll?

We recommend 30 minutes by default here. For many setups that is enough because day-ahead data does not change all the time. At the same time, the current slot and window status still stay fresh enough. If you intentionally want to react closer to quarter-hour slots, you can switch polling to 15 minutes yourself.

Is there already a warning for extreme hours?

Yes. The product already has a warning for possible extreme hours, for example in the app or web app via the Tail-Beta or extreme-path signal. In the ioBroker endpoint itself, however, that signal is not yet exposed as its own stable field.

Small test phase

A small test phase is currently running for the new ioBroker path. The most useful feedback comes from real setups, not theory alone. The key questions are: Is the setup understandable, do the values look plausible, and are the error states clear enough?

If you want to test this in a real ioBroker setup, send a short note with your setup, market and rough goal to StrompreisVorhersage@proton.me. The first integrations are still intentionally handled manually.

Related: Home Assistant and evcc.