pvnodepvnode
  • pvnode.com
  • Studio
  • Pricing
  • Deutsch
  • English
  • API Reference
Product
  • Studio
  • API Documentation
  • API Reference
  • Pricing
Resources
  • Quickstart
  • Integrations
Legal
  • Imprint
  • Privacy
  • Terms
  • Licenses
pvnodepvnode

© 2026 pvnode. All rights reserved.

linkedin
IntroductionQuickstartMigration from V1
Sites & Data
    Sites & StringsForecastsHistorical DataData UploadCalibration & Monitoring
Guides
Enterprise
Integrations
    Build Your OwnHome AssistantevccSolectrusioBroker
(Archive) V1 API
powered by Zudoku
Integrations

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:

TerminalCode
GET /v2/sites/ Authorization: Bearer YOUR_API_KEY
Code
[ {"id": "site_ai8wsa9jvu3y6uq...", "name": "Home", "timezone": "Europe/Berlin"}, {"id": "site_rv8wm5k2p1x4c7t...", "name": "Garage", "timezone": "Europe/Berlin"} ]

The recommended flow is two steps:

  1. 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.
  2. 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": ["default", "strings"], "available": ["default", "weather", "irradiance", "clearsky", "strings", "variability"]

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, available is 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
want_variability = "variability" in previous_response.get("available", [])

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
"computed_at": "2026-06-09T08:00:00+02:00", "next_poll_at": "2026-06-09T12:00:00+02:00"

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
delay = parse(response["next_poll_at"]) - now() + timedelta(seconds=30) delay = min(max(delay, timedelta(minutes=1)), timedelta(hours=24))

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
start → load stored payload → next_poll_at still in the future? → use it, schedule the next poll → otherwise → fetch

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
RequestLimit-Limit: 10000 RequestLimit-Used: 4231 RequestLimit-Remaining: 5769 RequestLimit-Reset: 2026-09-01T00:00:00Z

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:

Statusdetail containsMeaningRecommended reaction
401—Key invalid or revokedPrompt for a new key; don't retry in a loop.
403variabilityPlan lacks the bandNot an error. Drop the group, retry once.
403does not includePlan lacks Forecast API accessStop, name the plan the user needs.
403inactive / site limitSite deactivated by a downgradeStop, point at an upgrade or at deleting a site.
404—Site deletedStop, ask the user to reconfigure.
429—Monthly quota exhaustedBack off until RequestLimit-Reset, surface it.
5xx—TransientRetry 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
User-Agent: my-integration/1.4.0 SomePlatform/2026.8

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_id is 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-Agent identifies the client

Questions, or something missing? Write to support@pvnode.com.

Last modified on August 18, 2026
IntegrationsHome Assistant
On this page
  • 1. Setup: ask for a key, then let the user pick a site
  • 2. Discover what the plan allows, don't hard-code it
  • 3. Poll on next_poll_at, never on your own timer
  • 4. A restart must not cost a request
  • 5. Move your values locally between polls
  • 6. Read the quota headers
  • 7. Error handling
  • 8. Identify your client
  • Checklist
JSON
JSON
JSON