# IPS Controllers Partner API: developer guide

**Version 2.0.** This guide goes with `openapi.yaml` (also served as
`openapi.json`), which is the reference for every endpoint, field, event and
settings limit. The Integration Manual (`/docs/integration-manual.pdf`) covers
the same ground at more length, with the go-live checklist.

Base URL: `https://integrations.ipscontrollers.com/v2`

Partners on the original IPS API (`https://monitor.ipscontrollers.com/api/`):
it keeps working exactly as it does today. Version 2 is the partner platform
from here on. Move to it at your own pace, with your IPS partner contact.

---

## 1. The model in one paragraph

An IPS customer owns or manages pool and spa chemical controllers. Your
application never sees a customer's IPS password. Instead the customer signs in
to IPS, sees what your application is asking for, picks **which controllers** to
share, and approves. That approval is a **grant**. Your access token lets you act
inside that grant and nowhere else. The customer can narrow or revoke the grant
at any time in IPS, under **Connected apps**, and the change applies to your
very next request.

Only a controller's **Owner** or **Admin** can share it with an application.

## 2. Getting credentials

IPS registers your application and issues:

- a `client_id`;
- a `client_secret`, if your application can keep a secret (a server-side app);
- the exact redirect URIs you registered;
- the capabilities your integration is approved for (`read`, `settings_write`,
  `webhooks`);
- **demo accounts**: simulated customers with simulated controllers. Use these
  to build and test. They behave like real accounts, but no physical equipment
  is behind them.

### Demo and production clients

Your first client is a **demo client**: it can be connected by **demo accounts
only**, which sign in with `@demo.ipscontrollers.com` emails. IPS issues a
production client when your integration is ready. The API, scopes and events
are identical.

The simulated controllers behave like real ones:

- they report every five minutes, with three days of history from the start;
- an alarm is raised now and then and clears itself after about ten minutes;
- IPS can take a controller offline, or raise a test alarm, on request;
- a settings change you request is picked up within a minute and reported as
  `settings.applied`.

Webhooks are delivered within about a minute of the event.

## 3. Connecting a customer (OAuth 2.1, authorization code + PKCE)

PKCE (S256) is **required for every client**, including server-side ones.
`state` is required.

### Step 1: send the customer to IPS

Create a random `code_verifier` (43–128 characters) and derive
`code_challenge = BASE64URL(SHA256(code_verifier))`. Then redirect the customer to:

```
https://integrations.ipscontrollers.com/oauth/authorize
  ?response_type=code
  &client_id=YOUR_CLIENT_ID
  &redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fips%2Fcallback
  &scope=read%20webhooks
  &state=RANDOM_PER_REQUEST
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
```

The customer signs in, chooses controllers and permissions, and approves.

### Step 2: the customer comes back

IPS redirects to your `redirect_uri` with `code` and `state`. **Check that `state`
matches** what you sent. If the customer declined, you receive
`error=access_denied` instead.

### Step 3: exchange the code

```
POST https://integrations.ipscontrollers.com/oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=THE_CODE
&redirect_uri=https%3A%2F%2Fyourapp.example.com%2Fips%2Fcallback
&code_verifier=THE_CODE_VERIFIER
&client_id=YOUR_CLIENT_ID
&client_secret=YOUR_CLIENT_SECRET      (server-side apps only)
```

The response holds an `access_token` (valid for 15 minutes), a `refresh_token`
and the granted `scope`.

### Step 4: call the API

```
GET https://integrations.ipscontrollers.com/v2/me
Authorization: Bearer ACCESS_TOKEN
```

### Refreshing

```
grant_type=refresh_token&refresh_token=THE_REFRESH_TOKEN&client_id=…
```

**Refresh tokens rotate.** Every refresh returns a new refresh token, and the old
one stops working. If an old refresh token is ever presented again, IPS treats
that as theft and revokes the whole grant. Store the new token before you use
the new access token.

## 4. Permissions are checked on every call

Your token carries **no permissions**. On each request IPS works out your
effective permission on each controller from:

- what the customer shared with your application;
- the customer's own current role on that controller;
- the capabilities your integration is approved for.

The effective permission is **`view`** or **`view_and_settings`**. `GET /me` and
each controller's `permission` field tell you which one applies right now.

What this means for your code:

- **A controller that is not in the grant answers 404**, the same as one that
  does not exist. Do not treat a 404 on a known controller as a bug. The customer
  may have stopped sharing it.
- **403** means the controller is shared, but not for what you tried, such as a
  settings change on a view-only share.
- Do not cache permissions for long. Re-read `GET /me` when you get a 403 or 404
  you did not expect.

## 5. Reading data

| Call | Use it for |
|---|---|
| `GET /me` | the grant: the customer's display name and each shared controller with your permission on it |
| `GET /controllers` | the shared controllers, with status (`online`, `last_report_at`), model, name, location and MAC |
| `GET /controllers/{id}/readings?from=…&to=…` | pH, ORP, temperature, PPM, flow and tank states over a window of up to 31 days |
| `GET /controllers/{id}/alarms` | open alarms, then those cleared in the last 30 days |
| `GET /controllers/{id}/settings` | the settings the controller last reported, and any pending change |

- **Controller IDs are opaque** (`ctl_…`) and stable. Store them as they are.
- `mac` is for display only, for example to help a technician find the right
  box. Never build URLs from it.
- All times are UTC, in RFC 3339 format.
- A measurement a controller model does not have is `null`.
- Controllers report every few minutes, so polling readings more often than
  that returns nothing new. For alarms and status, use webhooks rather than
  polling.

### Pagination

List calls return `{ "data": [...], "next_cursor": "…" }`. Pass `next_cursor` back as
`cursor` to get the next page. `next_cursor` is `null` on the last page. `limit`
defaults to 100 and can be at most 500.

## 6. Changing settings (asynchronous)

These controllers dose pool chemicals, so a change is never applied instantly.

1. `PATCH /controllers/{id}/settings` with **only the values you want to change**:
   ```json
   { "ph": { "setpoint": 7.5, "high_alert": 7.8 } }
   ```
2. IPS validates the values against the same limits as the IPS app. If any are
   out of range, you get **422**, with each offending field and its allowed range
   in `error.detail`.
3. When the values are valid, you get **202** with `{ "change_id": "chg_…", "status": "pending" }`.
4. The controller picks up the change on its next check-in. You then receive
   `settings.applied` or `settings.failed`. You can also poll
   `GET /settings-changes/{change_id}`.

Only one change per controller can be pending at a time. A second request gets
**409**.

To request changes you need the `settings_write` capability, **and** the
customer must have shared the controller as "View and change settings".

## 7. Webhooks

### Subscribing

`POST /webhooks` with an HTTPS `url` and the `event_types` you want. The response
includes a **`signing_secret`, shown once only**. Store it securely.

| Event | When |
|---|---|
| `alarm.raised` / `alarm.cleared` | an alarm opens or clears on a shared controller |
| `controller.offline` / `controller.online` | a shared controller stops or resumes reporting |
| `settings.applied` / `settings.failed` | the outcome of a change you requested |
| `grant.revoked` | the customer disconnected your application. Stop using its tokens |
| `reading.created` | every reading. High volume, so subscribe only if you need it |

`POST /webhooks/{id}/test` sends a signed `webhook.test` event, so you can check
your endpoint end to end.

### Delivery

- Answer with any **2xx within 10 seconds**. Do the real work after you
  respond.
- Failed deliveries are retried with back-off. If deliveries keep failing for 24
  hours, the subscription's `health` becomes `unhealthy`. Events are kept and can
  be replayed.
- **Deliveries can repeat.** The event `id` (also sent in the `webhook-id`
  header) is the same on every retry. Use it to ignore duplicates.
- Order is not guaranteed. Use `occurred_at`.

### Verifying signatures (required)

Deliveries follow the [Standard Webhooks](https://www.standardwebhooks.com/)
specification. Every request carries three headers:

| Header | Value |
|---|---|
| `webhook-id` | the event ID |
| `webhook-timestamp` | Unix seconds when it was sent |
| `webhook-signature` | one or more `v1,<base64 signature>` values, separated by spaces |

To verify a delivery:

1. Take your secret, drop the `whsec_` prefix, and **base64-decode** the rest.
   The result is the key.
2. Build the signed content: `` `${webhook-id}.${webhook-timestamp}.${raw body}` ``.
   Use the **raw request body bytes**, not re-serialized JSON.
3. Compute HMAC-SHA256 of that content with the key, then base64-encode it.
4. Accept the delivery if it matches **any** `v1,` value in `webhook-signature`.
   Compare in constant time.
5. Reject it if `webhook-timestamp` is more than 5 minutes from your clock.
   This stops replays.

During secret rotation, several signatures are sent, which is why step 4 accepts
any match.

**Node.js**

```js
import crypto from "node:crypto";

export function verifyIpsWebhook(secret, headers, rawBody) {
  const id = headers["webhook-id"];
  const ts = headers["webhook-timestamp"];
  const sigHeader = headers["webhook-signature"];
  if (!id || !ts || !sigHeader) return false;
  if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${ts}.${rawBody}`)
    .digest();

  return sigHeader.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const given = Buffer.from(sig, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}
```

With Express, read the raw body: `app.post("/ips/events", express.raw({ type: "application/json" }), …)`,
then pass `req.body.toString("utf8")`.

**Python**

```python
import base64, hashlib, hmac, time

def verify_ips_webhook(secret: str, headers: dict, raw_body: bytes) -> bool:
    msg_id = headers.get("webhook-id")
    ts = headers.get("webhook-timestamp")
    sig_header = headers.get("webhook-signature")
    if not (msg_id and ts and sig_header):
        return False
    if abs(time.time() - int(ts)) > 300:
        return False

    key = base64.b64decode(secret.removeprefix("whsec_"))
    signed = f"{msg_id}.{ts}.".encode() + raw_body
    expected = base64.b64encode(hmac.new(key, signed, hashlib.sha256).digest()).decode()

    for part in sig_header.split(" "):
        version, _, sig = part.partition(",")
        if version == "v1" and hmac.compare_digest(sig, expected):
            return True
    return False
```

The official Standard Webhooks libraries (`standardwebhooks` on npm and PyPI)
verify these deliveries as well.

## 8. Errors

Every error has the same shape:

```json
{ "error": { "code": "insufficient_permission", "message": "…", "detail": … } }
```

| HTTP | `code` | What to do |
|---|---|---|
| 400 | `invalid_request` | fix the request; `message` says what is wrong |
| 401 | `invalid_token` | refresh the token. If refreshing fails, send the customer through authorization again |
| 403 | `insufficient_permission` | the grant does not allow it. The customer can widen access in IPS |
| 404 | `not_found` | not in this grant, or does not exist |
| 409 | `conflict` | a settings change is already pending for this controller |
| 422 | `validation_failed` | out-of-range values. `detail` lists each field |
| 429 | `rate_limited` | wait `Retry-After` seconds |
| 5xx | `server_error` | retry with back-off |

`message` is written to be shown to a person, but `code` is what your code
should branch on.

## 9. Rate limits

Limits are **600 requests per minute per application** and **120 per minute per
customer grant**, in fixed one-minute windows. Every response carries
`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset` (seconds). When you exceed the limit you get 429 with
`Retry-After`. Limits are per application and per grant, so one busy customer
does not use up another customer's limit.

## 10. Disconnection

When a customer disconnects your application, you receive `grant.revoked`. From
then on every token for that grant answers 401, and no further events are sent.
Remove the customer's data from your system as your own terms require. To
reconnect, the customer goes through authorization again.
