# Building an Integration

The [API Reference](/api) 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](/en/v2/integrations/home-assistant) 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.

:::tip
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:

```bash
GET /v2/sites/
Authorization: Bearer YOUR_API_KEY
```

```json
[
  {"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.

:::note
`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:

```json
"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.

```python
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:

```json
"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.

```python
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.

:::warning
**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.

```text
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:

```http
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.

:::warning
`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:

```http
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](mailto:support@pvnode.com).
