Ask in the forum

Webhooks

Push instead of polling — SpotpriceAPI calls your URL on forecast/plan updates. Saves Community quota (200/day). Complements Hours Demand and Forecast. Open REST for any client; Loxone typically via Miniserver HTTP out.

Webhooks mean SpotpriceAPI calls your server when something changes — instead of you asking every few minutes. Two events exist: new forecast ready, or your charging plan changed.

Everyday example

You run a small server at home. You register its URL in the SpotpriceAPI portal. When tomorrow’s day-ahead prices arrive, your server gets a POST: “forecast updated.” It fetches fresh data once — no timer running all night.

When to use / when not

Use when:

  • You want fewer API polls and fewer rate-limit hits
  • Your EMS must react quickly when charge_hours change

Do not use when:

  • You have no server reachable from the internet (use polling or Loxone Virtual Output instead)
  • You only need prices occasionally — hourly polling is fine

What you need

  • HTTPS URL on your server (SpotpriceAPI POSTs to you)
  • Signing secret from the portal (to verify requests are genuine)
  • Optional: Forecast or Hours Demand to fetch full data after the ping

Step by step

1. In the portal go to /settings/webhook (or Partner site card for multiple sites). 2. Enter your HTTPS URL, e.g. https://your-home.example/webhook/spotforecast. 3. Copy the signing secret — store it safely. 4. Click Test in the portal — confirm your server receives the POST. 5. On every request, verify header X-Spotforecast-Signature (see below). 6. On forecast.updated → optionally GET /v1/forecast. On plant.plan_updated → use charge_hours from the payload or re-call Hours Demand.

How verification works

SpotpriceAPI sends header:


X-Spotforecast-Signature: t=<unix_timestamp>,v1=<hex_hmac>

Build the string {timestamp}.{raw_body}, HMAC-SHA256 with your secret, compare to v1. Reject if timestamp is older than ~5 minutes.


import hashlib, hmac, time

def verify(secret, raw_body, header, tolerance=300):
    parts = dict(p.split("=", 1) for p in header.split(","))
    ts = int(parts["t"])
    if abs(time.time() - ts) > tolerance:
        return False
    expected = hmac.new(secret.encode(), f"{ts}.{raw_body.decode()}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, parts["v1"])

Events

EventWhen it fires
forecast.updatedAfter a new forecast run (scheduled, day-ahead, manual)
plant.plan_updatedWhen hours_needed, charge_hours[], O12, O11/charge_power_kw, hold Caps, the Decide PV pack (pv_hash), grid_charge_allowed, latch/charge_now/charge_stop_soc_pct/charge_stop_hysteresis_pp, or hours_control_paused (cold-start / Dual-Cycle warm-up enter/exit, engine hours_demand_v1.36) change materially (including near-min, session-complete, and profile-save tombstone empties)

Example plant.plan_updated payload


{
  "event": "plant.plan_updated",
  "issued_at": "2026-08-17T18:40:00+02:00",
  "hours_needed": 3,
  "hours_needed_today": 2,
  "energy_charge_kwh": 4,
  "charge_hours_today": [
    { "start": "2026-08-17T19:00:00+02:00", "end": "2026-08-17T20:00:00+02:00" }
  ],
  "hours_demand_url": "https://api.spotpriceapi.com/v1/decision/hours-demand",
  "prefix_hours_needed_today": 2,
  "prefix_period_hours_today": 10,
  "prefix_energy_charge_kwh": 4,
  "charge_now": 1,
  "charge_stop_soc_pct": 72,
  "charge_latched": false,
  "charge_stop_hysteresis_pp": 2.0,
  "hours_control_paused": false,
  "discharge_power_kw": 9,
  "discharge_reason": "recharge_planned",
  "adaptive_discharge": true,
  "min_soc_pct": 20,
  "charge_power_kw": 7.9,
  "grid_charge_allowed": true,
  "pv_forecast_url": "https://api.spotpriceapi.com/v1/plant/pv-forecast",
  "pv_issued_at": "2026-08-17T16:40:00+00:00",
  "pv_hash": "c0ffee12abcd3456",
  "plan_hash": "a1b2c3d4e5f67890"
}

discharge_power_kw (O12) is the full cap except surplus-Cap Hold (from hours_demand_v1.20, live v1.36): slot Cap (hold_share | pack_reserve 0…0.2 | Weak/cheap_hold≈1 kW) whenever Now ∈ Hold-Set (no second cover gate), below the curve top as SoC falls (0 at or below the minimum, including when a later charge is planned), or while the wallbox is charging (ev_charge_block / ev_charge_limit). Cold-start mute: charge_now=0, charge_stop_soc_pct: null, hours_control_paused: true. Charge hours: full cap while actively charging only from the curve top (charge_now and not latched/grid-off); below that, the ramp. Latched/grid-off → no Vollcap, Hold Cap when Now ∈ Hold. A change of the O12 cap, O11/charge_power_kw (profile max grid charge / Anschluss), hold starts or hold Caps (hold_hours incl. Cap vector (start, discharge_cap_kw)), the PV pack (pv_hash), or grid_charge_allowed or hours_control_paused also fires plant.plan_updated. After profile save on Decide fail/incomplete: push with empty charge slots + discharge_power_kw=0/charge_now=0 + current grid_charge_allowed (no stale grid charges/O12 on the EMS; sticky charge_latched/charge_stop_soc_pct/charge_stop_hysteresis_pp kept, H7; Loxone .bak remains until a successful VO pull). After the event optionally GET /v1/plant/pv-forecast (pv_forecast_url) — served D0 with PV meter = actual+forecast like Portal/Sparvo (pack prefer ≤6 h stores model FC; on weather refresh the server also rewrites the pack without a new webhook). A PV meter tick alone updates served D0 but does not fire plant.plan_updated (pv_hash = pack FC).

Full week plan: The event only embeds charge_hours_today (compact). After every plant.plan_updated, pull GET /v1/decision/hours-demand with mode=decide for day_plan / full charge_hours / hold_hours. Optional plan_hash (16 chars, SHA-256 of plan starts) for dedupe. Materiality checks today and full plan starts and hold starts and hold Cap vector and O11/charge_power_kw and pv_hash and grid_charge_allowed and charge_now and charge_stop_soc_pct and charge_latched and charge_stop_hysteresis_pp and hours_control_paused. Fingerprint advances only on push OK (not_configured/HTTP fail → retry on next Decide/DA). Pure SOC correction without slot, O12, O11, hold, PV, grid-gate, charge_now, latch, H, stop, or pause change (e.g. cover refine v1.9) does not emit an event.

Example forecast.updated payload fields

FieldWhat it means
eventforecast.updated
issued_atWhen the event was sent
model_versionForecast model version
triggere.g. scheduled, manual
forecast_urlWhere to GET full data

Partner accounts may also get a site object (api_key_id, label, customer_ref).

Common mistakes

  • HTTP not HTTPS — registration requires HTTPS (HTTP and private/loopback hosts are rejected)
  • Not verifying signature — anyone could POST fake events to your URL
  • Returning non-2xx — a single delivery attempt is recorded; repeated failures show as “failing” in the portal (no automatic retry)
  • Expecting full forecast JSON in the webhook — usually you GET it yourself after the ping

Next steps

Start with SpotpriceAPI

Register for Community or code samples with your personal key.

Live API status