Building an Integration
The API Reference tells you what the endpoints do. This page describes how a client that runs unattended in someone's home — Home Assistant, ioBroker, ... — should behave to be cheap, self-healing and pleasant to set up.
The rules below are the ones the official Home Assistant integration follows. If you are writing your own client, following them will keep it out of the two failure modes we see most often: burning a user's quota on cached data, and breaking when the user changes plan.
The single most important rule: cached responses cost a request. pvnode does not
recompute on a schedule, so asking again before next_poll_at returns the identical payload
and still decrements the quota.
1. Setup: ask for a key, then let the user pick a site
Never ask a user to paste a site_id. Reading sites is open on every plan, so a client
can always list them:
Code
Code
The recommended flow is two steps:
- API key. Validate it by calling
GET /v2/sites/. A 401 means the key is wrong — and you have not spent any forecast quota finding out. - Site picker. Show the names, store the
id. With exactly one site, skip the step and select it automatically.
Use the site_id as the stable identity of the configuration — it is what makes a
duplicate setup detectable, and it survives the site being renamed.
This call also returns each site's strings with their slope, orientation and power_kw,
which is what you need to build readable names for per-string entities. Strings carry no
name field, so derive one ("South 30° · 9.9 kWp") rather than showing a raw index.
GET /v2/sites/ and GET /v2/sites/{site_id} do not count against the forecast quota and
carry no RequestLimit-* headers. Call them during setup, and again when the string layout
might have changed — but not on every refresh; cheap is not free.
2. Discover what the plan allows, don't hard-code it
Every forecast response carries both:
Code
included is what you asked for. available is what the plan would allow. Drive your
feature set from available and a plan change needs no new release and no user action:
- First request: ask conservatively — leave out plan-gated groups.
- Read
available. If it lists a group you want, request it from the next poll on. - On a downgrade,
availableis one response out of date: it can still promise a group that has just been withdrawn. Treat the resulting 403 as a signal, not a failure — drop the group, retry once without it, and keep running.
Today variability is the only plan-gated group. Requesting it without the entitlement is
rejected before the request is counted, so probing costs no quota.
Code
On a fresh install you have no previous response, so probe once with the group. The 403 is free and settles the question immediately.
3. Poll on next_poll_at, never on your own timer
next_poll_at is the first moment a request returns something newer:
Code
Derive your next wake-up from it rather than from a fixed interval. A hard-coded "every 30 minutes" is wrong in both directions: wasteful on a plan with one update per day, stale on a plan with 96.
Code
Three details worth copying:
- A small grace period (30 s or so) keeps you from landing a moment before the slot boundary and getting the old payload anyway.
- Clamp the result. Not because the server is expected to misbehave, but because a negative or absurd delay would otherwise become a request loop. One minute to 24 hours is a sane window.
- Have a fallback. If the field is missing, pick a conservative interval (an hour) and keep going rather than stopping.
next_poll_at is a recommendation, not a guarantee — another request can trigger an
earlier recomputation. Polling on it is the cheapest correct strategy, not the freshest
possible one.
Do not reconstruct the schedule yourself. It is tempting to work out where the slots fall and set a timer to match. Don't: how they are placed is going to change — we intend to spend fewer of them at night, when nothing is generating, and more around the hours that matter. A client that reads the field follows that automatically. One that computed its own schedule will quietly start polling at the wrong times.
4. A restart must not cost a request
Persist the last payload together with its next_poll_at. On startup, if that timestamp is
still in the future, restore from disk and show the data immediately — without calling the
API.
Code
Without this, every restart, every config reload and every crash loop spends quota on a payload the client already had. On a plan with one update per day, a handful of restarts can exhaust the day's usefulness entirely.
The stored available is safe to trust on restore: it records the plan's entitlement, not
whatever happened to be requested at the time.
5. Move your values locally between polls
The forecast is a 15-minute time series covering days. Values like "power right now", "remaining energy today" or "peak time" are derived from data you already hold — they do not need a new request.
Recompute them from the stored series on a local timer (every five minutes is plenty) and push the update to your UI. A user on a plan with one update per day still gets a reading that moves through the day.
Only the forecast curve needs polling. Everything you read off it does not.
6. Read the quota headers
Every forecast and historical response — including the 429 — carries:
Code
Surfacing Remaining is worth doing: it makes the cost of a manual refresh button visible,
and it explains a stalled integration at a glance.
Limit and Remaining are strings. On plans without a cap they contain the literal word
unmetered, not a number. Parse defensively; int(header) will raise for those users.
The counter runs per (user, endpoint, month). Two consequences for a client:
- The numbers are account-wide, not per site. If you support several sites, do not label the value "requests left for this site" — every instance will show the same figure.
- Forecast and historical have separate budgets. Spending one does not affect the other.
On a 429, stop polling until RequestLimit-Reset instead of retrying. The quota resets on
the 1st of the month at 00:00 UTC.
7. Error handling
pvnode uses 403 for several unrelated situations, and a good client reacts differently to
each. Distinguish them by the detail text:
| Status | detail contains | Meaning | Recommended reaction |
|---|---|---|---|
| 401 | — | Key invalid or revoked | Prompt for a new key; don't retry in a loop. |
| 403 | variability | Plan lacks the band | Not an error. Drop the group, retry once. |
| 403 | does not include | Plan lacks Forecast API access | Stop, name the plan the user needs. |
| 403 | inactive / site limit | Site deactivated by a downgrade | Stop, point at an upgrade or at deleting a site. |
| 404 | — | Site deleted | Stop, ask the user to reconfigure. |
| 429 | — | Monthly quota exhausted | Back off until RequestLimit-Reset, surface it. |
| 5xx | — | Transient | Retry with backoff; keep showing the stored forecast. |
A transient failure should never blank the UI. Keep the last good forecast on screen and mark it stale — a forecast from an hour ago is still a useful forecast.
8. Identify your client
Send a User-Agent naming your integration and its version:
Code
It rides along on requests that happen anyway and carries nothing about the user. It lets us see which integrations are actually in use, and reach out to maintainers before a change affects them.
Checklist
- Site chosen from
GET /v2/sites/, never typed in by hand -
site_idis the stable identity of the configuration - Feature set driven by
available, not by hard-coded plan names - A withdrawn group causes a retry, not a failed setup
- Poll interval derived from
next_poll_at, clamped, with a fallback - Last payload persisted — a restart costs no request
- Derived values recomputed locally between polls
-
RequestLimit-*parsed defensively (unmetered!) and surfaced - 429 backs off until
Reset - The three meanings of 403 handled separately
- Stale data kept on screen during an outage
-
User-Agentidentifies the client
Questions, or something missing? Write to support@pvnode.com.