{
  "openapi": "3.1.0",
  "info": {
    "title": "IPS Controllers Partner API",
    "version": "2.0.0",
    "summary": "Access IPS pool and spa chemical controllers on behalf of a customer who has approved it.",
    "description": "The IPS Controllers Partner API lets your application read the controllers a\ncustomer has chosen to share with you, receive events from them, and (where\nyou are approved for it) request settings changes.\n\n**How access works.** A customer signs in to IPS and approves your\napplication (OAuth 2.1 authorization code with PKCE). Only a controller's\nOwner or Admin can share it. The customer chooses\nWHICH controllers to share and, per controller, whether you may only view it\nor may also request settings changes. That approval is a **grant**.\n\n- Every call is checked against the customer's grant **at the time of the\n  call**. If the customer narrows or revokes access, the change applies to\n  your very next request. Tokens never carry permissions themselves.\n- A controller that is not in the grant answers **404**, exactly as if it did\n  not exist.\n- Settings changes are **requests**: they are accepted with 202 and applied\n  to the device on its next check-in. The outcome arrives as a\n  `settings.applied` or `settings.failed` webhook. These controllers dose\n  pool chemicals, so nothing is applied instantly.\n\n**Environments.** One host serves both. Your IPS contact issues you a\n**demo client** first: it can be connected only by demo customer accounts\n(`@demo.ipscontrollers.com`), whose controllers are simulated and produce\nlive readings, alarms and events. A **production client** is issued when\nyour integration is ready. The API, scopes and events are identical.\n\n**Limits.** 600 requests per minute per client and 120 per minute per\ncustomer grant, in fixed one-minute windows. Every response carries\n`RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`.\n\n## Rules for code generators and AI agents\n\n1. Get an access token through the authorization code flow with PKCE (S256)\n   and `state`. There is no client-credentials flow: every token acts for\n   one customer's grant.\n2. Call `GET /me` first. It lists the controllers you may use and your\n   effective `permission` on each. Use those `id` values. Never derive IDs\n   from MAC addresses or names.\n3. Permissions are re-checked on every call and can shrink at any time.\n   Treat **401** as \"refresh the token, or reconnect the customer\", **403**\n   as \"not allowed for this controller now\", and **404** as \"not shared, or\n   no longer shared\". Do not retry 403 or 404.\n4. Settings changes are asynchronous. `PATCH /controllers/{id}/settings`\n   answers **202** with a `change_id`. Wait for the `settings.applied` or\n   `settings.failed` webhook, or poll `GET /settings-changes/{change_id}`.\n   Send only the fields you change, within the ranges given on\n   `SettingsBlock`. A **422** lists each failing field and its allowed\n   values. These controllers dose pool chemicals: never loop or retry\n   changes automatically.\n5. Pages: pass `next_cursor` back as `cursor` until it is `null`. Readings\n   need `from` and `to`, at most 31 days apart.\n6. On **429**, wait `Retry-After` seconds.\n7. Verify every webhook delivery (Standard Webhooks, HMAC-SHA256) before\n   trusting it. Deduplicate on the event `id`.\n\nThe Integration Manual (PDF) and the developer guide have the full OAuth\nwalk-through and webhook verification code.\n",
    "contact": {
      "name": "IPS Controllers Partner Support",
      "url": "https://integrations.ipscontrollers.com/docs"
    },
    "license": {
      "name": "Proprietary. For IPS Controllers partners under their partner agreement.",
      "identifier": "LicenseRef-IPS-Partner"
    }
  },
  "externalDocs": {
    "description": "Integration Manual and developer guide",
    "url": "https://integrations.ipscontrollers.com/docs"
  },
  "servers": [
    {
      "url": "https://integrations.ipscontrollers.com/v2",
      "description": "Production"
    }
  ],
  "security": [
    {
      "ipsOAuth": []
    }
  ],
  "tags": [
    {
      "name": "Grant",
      "description": "What the current customer has shared with you."
    },
    {
      "name": "Controllers",
      "description": "Controllers in the grant, their readings, alarms and settings."
    },
    {
      "name": "Settings changes",
      "description": "Asynchronous settings changes and their status."
    },
    {
      "name": "Webhooks",
      "description": "Your event subscriptions."
    }
  ],
  "paths": {
    "/me": {
      "get": {
        "tags": [
          "Grant"
        ],
        "operationId": "getMe",
        "summary": "The grant behind this access token",
        "description": "The customer who approved your application, and each shared controller with your effective permission on it.",
        "responses": {
          "200": {
            "description": "The grant.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Me"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/controllers": {
      "get": {
        "tags": [
          "Controllers"
        ],
        "operationId": "listControllers",
        "summary": "List the controllers in the grant",
        "security": [
          {
            "ipsOAuth": [
              "read"
            ]
          }
        ],
        "parameters": [
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of controllers.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "next_cursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Controller"
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/controllers/{controller_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ControllerId"
        }
      ],
      "get": {
        "tags": [
          "Controllers"
        ],
        "operationId": "getController",
        "summary": "One controller's status",
        "security": [
          {
            "ipsOAuth": [
              "read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The controller.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Controller"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/controllers/{controller_id}/readings": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ControllerId"
        }
      ],
      "get": {
        "tags": [
          "Controllers"
        ],
        "operationId": "listReadings",
        "summary": "Readings in a time window",
        "description": "Readings between `from` (inclusive) and `to` (exclusive), newest first.\nThe window may be at most 31 days. Page through it with `cursor`.\n",
        "security": [
          {
            "ipsOAuth": [
              "read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-10-20T00:00:00Z"
          },
          {
            "name": "to",
            "in": "query",
            "required": true,
            "schema": {
              "type": "string",
              "format": "date-time"
            },
            "example": "2026-10-21T00:00:00Z"
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of readings.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "next_cursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Reading"
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    }
                  }
                }
              }
            }
          },
          "400": {
            "$ref": "#/components/responses/BadRequest"
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/controllers/{controller_id}/alarms": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ControllerId"
        }
      ],
      "get": {
        "tags": [
          "Controllers"
        ],
        "operationId": "listAlarms",
        "summary": "Open alarms, then recent ones",
        "description": "Open alarms first, then alarms cleared in the last 30 days, newest first.",
        "security": [
          {
            "ipsOAuth": [
              "read"
            ]
          }
        ],
        "parameters": [
          {
            "name": "state",
            "in": "query",
            "required": false,
            "schema": {
              "type": "string",
              "enum": [
                "open",
                "cleared",
                "all"
              ],
              "default": "all"
            }
          },
          {
            "$ref": "#/components/parameters/Cursor"
          },
          {
            "$ref": "#/components/parameters/Limit"
          }
        ],
        "responses": {
          "200": {
            "description": "One page of alarms.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data",
                    "next_cursor"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/Alarm"
                      }
                    },
                    "next_cursor": {
                      "$ref": "#/components/schemas/NextCursor"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/controllers/{controller_id}/settings": {
      "parameters": [
        {
          "$ref": "#/components/parameters/ControllerId"
        }
      ],
      "get": {
        "tags": [
          "Controllers"
        ],
        "operationId": "getSettings",
        "summary": "Current settings",
        "description": "The settings the controller last reported, and whether a change is pending.",
        "security": [
          {
            "ipsOAuth": [
              "read"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The settings.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Settings"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      },
      "patch": {
        "tags": [
          "Settings changes"
        ],
        "operationId": "requestSettingsChange",
        "summary": "Request a settings change (asynchronous)",
        "description": "Requests new values for some settings. Send only the values you want to\nchange. Values are validated against the same limits the IPS app\nenforces.\n\nThe change is **accepted, not applied**: the response is 202 with a\n`change_id`. The controller picks the change up on its next check-in,\nand the outcome arrives as `settings.applied` or `settings.failed`.\nYou can also poll `GET /settings-changes/{change_id}`.\n\nNeeds the `settings_write` scope AND \"View and change settings\" on this\ncontroller in the customer's grant. Without it the answer is 403.\n",
        "security": [
          {
            "ipsOAuth": [
              "settings_write"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/SettingsChangeRequest"
              }
            }
          }
        },
        "responses": {
          "202": {
            "description": "The change was accepted and is pending.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "change_id",
                    "status"
                  ],
                  "properties": {
                    "change_id": {
                      "type": "string",
                      "example": "chg_01JB7Z3K5Q8W2R4T6Y8U0I2O4P"
                    },
                    "status": {
                      "type": "string",
                      "const": "pending"
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "409": {
            "description": "Another change for this controller is still pending. Wait for its outcome, then try again.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/Error"
                }
              }
            }
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/settings-changes/{change_id}": {
      "parameters": [
        {
          "name": "change_id",
          "in": "path",
          "required": true,
          "description": "The `change_id` returned by `PATCH /controllers/{controller_id}/settings`.",
          "schema": {
            "type": "string",
            "example": "chg_01JB7Z3K5Q8W2R4T6Y8U0I2O4P"
          }
        }
      ],
      "get": {
        "tags": [
          "Settings changes"
        ],
        "operationId": "getSettingsChange",
        "summary": "The status of a settings change you requested",
        "description": "`pending` until the controller picks the change up, then `applied` or\n`failed`. `expired` is reserved for a change the controller never picks up. Prefer the\n`settings.applied` and `settings.failed` webhooks to polling; if you\npoll, poll no more than once a minute.\n",
        "security": [
          {
            "ipsOAuth": [
              "settings_write"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The change.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/SettingsChange"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "429": {
            "$ref": "#/components/responses/RateLimited"
          }
        }
      }
    },
    "/webhooks": {
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "listWebhooks",
        "summary": "Your webhook subscriptions",
        "description": "Every subscription of this client, with its delivery health. Signing secrets are never returned here.",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "Your subscriptions.",
            "headers": {
              "RateLimit-Limit": {
                "$ref": "#/components/headers/RateLimit-Limit"
              },
              "RateLimit-Remaining": {
                "$ref": "#/components/headers/RateLimit-Remaining"
              },
              "RateLimit-Reset": {
                "$ref": "#/components/headers/RateLimit-Reset"
              }
            },
            "content": {
              "application/json": {
                "schema": {
                  "type": "object",
                  "required": [
                    "data"
                  ],
                  "properties": {
                    "data": {
                      "type": "array",
                      "items": {
                        "$ref": "#/components/schemas/WebhookSubscription"
                      }
                    }
                  }
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          }
        }
      },
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "createWebhook",
        "summary": "Subscribe an HTTPS endpoint to events",
        "description": "Creates a subscription. The response carries the subscription's\n**signing secret, shown this once only**. Store it: you need it to\nverify every delivery (see the developer guide).\n\n**Delivery.** Each event is POSTed as JSON with the Standard Webhooks\nheaders. Any 2xx within 10 seconds counts as delivered. Anything else\nis retried after 10 s, 1 min, 5 min, 30 min, 1 h, 2 h, 4 h, 6 h and 8 h,\nthen every 10 h, for 24 hours in all. After that the delivery is kept\nas dead and IPS can replay it. Each subscription has its own delivery\nqueue, so a slow endpoint never delays another. The URL must be public\nHTTPS; private and loopback addresses are refused.\n",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionCreate"
              }
            }
          }
        },
        "responses": {
          "201": {
            "description": "Created. `signing_secret` is returned only here.",
            "content": {
              "application/json": {
                "schema": {
                  "allOf": [
                    {
                      "$ref": "#/components/schemas/WebhookSubscription"
                    },
                    {
                      "type": "object",
                      "required": [
                        "signing_secret"
                      ],
                      "properties": {
                        "signing_secret": {
                          "type": "string",
                          "description": "Standard Webhooks secret. Shown once.",
                          "example": "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
                        }
                      }
                    }
                  ]
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "403": {
            "$ref": "#/components/responses/Forbidden"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        }
      }
    },
    "/webhooks/{webhook_id}": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WebhookId"
        }
      ],
      "get": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "getWebhook",
        "summary": "One subscription, with its delivery health",
        "description": "`health` becomes `unhealthy` after deliveries have failed for 24 hours. IPS keeps the undelivered events and can replay them once your endpoint is fixed.",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "responses": {
          "200": {
            "description": "The subscription.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscription"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      },
      "patch": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "updateWebhook",
        "summary": "Change a subscription's URL, events or status",
        "description": "Send only what changes. `paused` stops deliveries until it is set back to `active`. The signing secret does not change; to rotate it, create a new subscription and delete the old one.",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "requestBody": {
          "required": true,
          "content": {
            "application/json": {
              "schema": {
                "$ref": "#/components/schemas/WebhookSubscriptionUpdate"
              }
            }
          }
        },
        "responses": {
          "200": {
            "description": "The updated subscription.",
            "content": {
              "application/json": {
                "schema": {
                  "$ref": "#/components/schemas/WebhookSubscription"
                }
              }
            }
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          },
          "422": {
            "$ref": "#/components/responses/ValidationFailed"
          }
        }
      },
      "delete": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "deleteWebhook",
        "summary": "Remove a subscription",
        "description": "Deletes the subscription and its signing secret. Events already queued for it are not delivered.",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "responses": {
          "204": {
            "description": "Removed. No further deliveries are made."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    },
    "/webhooks/{webhook_id}/test": {
      "parameters": [
        {
          "$ref": "#/components/parameters/WebhookId"
        }
      ],
      "post": {
        "tags": [
          "Webhooks"
        ],
        "operationId": "testWebhook",
        "summary": "Send a signed test event to the endpoint",
        "description": "Sends a `webhook.test` event, signed exactly as real events are, so you can check your verification code end to end.",
        "security": [
          {
            "ipsOAuth": [
              "webhooks"
            ]
          }
        ],
        "responses": {
          "202": {
            "description": "The test event was queued for delivery."
          },
          "401": {
            "$ref": "#/components/responses/Unauthorized"
          },
          "404": {
            "$ref": "#/components/responses/NotFound"
          }
        }
      }
    }
  },
  "webhooks": {
    "alarm.raised": {
      "post": {
        "operationId": "onAlarmRaised",
        "summary": "An alarm was raised on a shared controller",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "alarm.raised"
                      },
                      "data": {
                        "$ref": "#/components/schemas/Alarm"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged. Any 2xx counts as delivered."
          }
        }
      }
    },
    "alarm.cleared": {
      "post": {
        "operationId": "onAlarmCleared",
        "summary": "An alarm on a shared controller cleared",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "alarm.cleared"
                      },
                      "data": {
                        "$ref": "#/components/schemas/Alarm"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "controller.offline": {
      "post": {
        "operationId": "onControllerOffline",
        "summary": "A shared controller stopped reporting",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "controller.offline"
                      },
                      "data": {
                        "type": "object",
                        "required": [
                          "last_report_at"
                        ],
                        "properties": {
                          "last_report_at": {
                            "type": [
                              "string",
                              "null"
                            ],
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "controller.online": {
      "post": {
        "operationId": "onControllerOnline",
        "summary": "A shared controller started reporting again",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "controller.online"
                      },
                      "data": {
                        "type": "object",
                        "required": [
                          "last_report_at"
                        ],
                        "properties": {
                          "last_report_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "settings.applied": {
      "post": {
        "operationId": "onSettingsApplied",
        "summary": "A settings change you requested reached the device",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "settings.applied"
                      },
                      "data": {
                        "$ref": "#/components/schemas/SettingsChange"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "settings.failed": {
      "post": {
        "operationId": "onSettingsFailed",
        "summary": "A settings change you requested did not reach the device",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "settings.failed"
                      },
                      "data": {
                        "$ref": "#/components/schemas/SettingsChange"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "grant.revoked": {
      "post": {
        "operationId": "onGrantRevoked",
        "summary": "The customer disconnected your application",
        "description": "Sent once. After it, every token for this grant is invalid and no further events are delivered for it.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "grant.revoked"
                      },
                      "controller_id": {
                        "type": "null"
                      },
                      "data": {
                        "type": "object",
                        "required": [
                          "grant_id",
                          "revoked_at"
                        ],
                        "properties": {
                          "grant_id": {
                            "type": "string"
                          },
                          "revoked_at": {
                            "type": "string",
                            "format": "date-time"
                          }
                        }
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "reading.created": {
      "post": {
        "operationId": "onReadingCreated",
        "summary": "A new reading (off by default; high volume)",
        "description": "Every reading from every shared controller, typically one every few minutes per controller. Subscribe only if you need every reading; most integrations should poll readings or use alarms.",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "reading.created"
                      },
                      "data": {
                        "$ref": "#/components/schemas/Reading"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    },
    "webhook.test": {
      "post": {
        "operationId": "onWebhookTest",
        "summary": "A test event from POST /webhooks/{webhook_id}/test",
        "requestBody": {
          "content": {
            "application/json": {
              "schema": {
                "allOf": [
                  {
                    "$ref": "#/components/schemas/EventEnvelope"
                  },
                  {
                    "type": "object",
                    "properties": {
                      "type": {
                        "const": "webhook.test"
                      },
                      "controller_id": {
                        "type": "null"
                      },
                      "data": {
                        "type": "object"
                      }
                    }
                  }
                ]
              }
            }
          }
        },
        "responses": {
          "2XX": {
            "description": "Acknowledged."
          }
        }
      }
    }
  },
  "components": {
    "securitySchemes": {
      "ipsOAuth": {
        "type": "oauth2",
        "description": "OAuth 2.1 authorization code flow. PKCE (S256) is REQUIRED for every\nclient, including confidential ones. `state` is required. Redirect URIs\nmust match exactly. Access tokens are short-lived (15 minutes); refresh\ntokens rotate on every use, and reusing an old refresh token revokes the\ngrant.\n",
        "flows": {
          "authorizationCode": {
            "authorizationUrl": "https://integrations.ipscontrollers.com/oauth/authorize",
            "tokenUrl": "https://integrations.ipscontrollers.com/oauth/token",
            "refreshUrl": "https://integrations.ipscontrollers.com/oauth/token",
            "scopes": {
              "read": "Read the shared controllers, their readings, alarms and settings.",
              "settings_write": "Request settings changes on controllers the customer allowed.",
              "webhooks": "Manage webhook subscriptions for events on shared controllers."
            }
          }
        }
      }
    },
    "parameters": {
      "ControllerId": {
        "name": "controller_id",
        "in": "path",
        "required": true,
        "description": "The controller's opaque public ID. It is stable, and is never a database ID or a MAC address.",
        "schema": {
          "type": "string",
          "example": "ctl_7Hq2mV9xLp3sK8dR"
        }
      },
      "WebhookId": {
        "name": "webhook_id",
        "in": "path",
        "required": true,
        "schema": {
          "type": "string",
          "example": "whk_2Nf8sQ1rT6yU4iO9"
        }
      },
      "Cursor": {
        "name": "cursor",
        "in": "query",
        "required": false,
        "description": "The `next_cursor` from the previous page. Omit for the first page.",
        "schema": {
          "type": "string"
        }
      },
      "Limit": {
        "name": "limit",
        "in": "query",
        "required": false,
        "schema": {
          "type": "integer",
          "minimum": 1,
          "maximum": 500,
          "default": 100
        }
      }
    },
    "headers": {
      "RateLimit-Limit": {
        "description": "Requests allowed in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Remaining": {
        "description": "Requests left in the current window.",
        "schema": {
          "type": "integer"
        }
      },
      "RateLimit-Reset": {
        "description": "Seconds until the window resets.",
        "schema": {
          "type": "integer"
        }
      }
    },
    "responses": {
      "BadRequest": {
        "description": "The request is malformed. `error.message` says what to fix.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Unauthorized": {
        "description": "The access token is missing, expired or revoked. Refresh it, or send the customer through authorization again.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "Forbidden": {
        "description": "The grant does not allow this. The customer can widen access in IPS, under Connected apps.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "NotFound": {
        "description": "No such resource in this grant. A controller the customer has not shared answers 404.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "ValidationFailed": {
        "description": "One or more values are out of range. `error.detail` lists each field and its allowed range.",
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      },
      "RateLimited": {
        "description": "Too many requests. Retry after `RateLimit-Reset` seconds.",
        "headers": {
          "Retry-After": {
            "schema": {
              "type": "integer"
            }
          }
        },
        "content": {
          "application/json": {
            "schema": {
              "$ref": "#/components/schemas/Error"
            }
          }
        }
      }
    },
    "schemas": {
      "Error": {
        "type": "object",
        "required": [
          "error"
        ],
        "properties": {
          "error": {
            "type": "object",
            "required": [
              "code",
              "message"
            ],
            "properties": {
              "code": {
                "type": "string",
                "enum": [
                  "invalid_request",
                  "invalid_token",
                  "insufficient_permission",
                  "not_found",
                  "conflict",
                  "validation_failed",
                  "rate_limited",
                  "server_error"
                ],
                "description": "`invalid_request` 400, `invalid_token` 401,\n`insufficient_permission` 403, `not_found` 404, `conflict` 409,\n`validation_failed` 422, `rate_limited` 429, `server_error` 500.\n"
              },
              "message": {
                "type": "string",
                "description": "What went wrong, and what to do next."
              },
              "detail": {
                "description": "Extra structure for the error, such as per-field validation failures.",
                "oneOf": [
                  {
                    "type": "object"
                  },
                  {
                    "type": "array"
                  }
                ]
              }
            }
          }
        },
        "example": {
          "error": {
            "code": "insufficient_permission",
            "message": "The customer has shared this controller for viewing only. They can allow settings changes in IPS, under Connected apps."
          }
        }
      },
      "NextCursor": {
        "type": [
          "string",
          "null"
        ],
        "description": "Pass as `cursor` to get the next page. `null` on the last page."
      },
      "Permission": {
        "type": "string",
        "enum": [
          "view",
          "view_and_settings"
        ],
        "description": "Your EFFECTIVE permission on a controller: the customer's grant, limited\nby the customer's own role on that controller and by what your\nintegration offers. It can change between calls.\n"
      },
      "Me": {
        "type": "object",
        "required": [
          "grant_id",
          "customer",
          "controllers",
          "scopes"
        ],
        "properties": {
          "grant_id": {
            "type": "string",
            "example": "grt_5Xc1vB7nM3qW9eR2"
          },
          "customer": {
            "type": "object",
            "required": [
              "display_name"
            ],
            "properties": {
              "display_name": {
                "type": "string",
                "example": "Sunset Pools"
              }
            }
          },
          "scopes": {
            "type": "array",
            "items": {
              "type": "string",
              "enum": [
                "read",
                "settings_write",
                "webhooks"
              ]
            }
          },
          "controllers": {
            "type": "array",
            "items": {
              "type": "object",
              "required": [
                "id",
                "name",
                "permission"
              ],
              "properties": {
                "id": {
                  "type": "string"
                },
                "name": {
                  "type": "string"
                },
                "permission": {
                  "$ref": "#/components/schemas/Permission"
                }
              }
            }
          }
        }
      },
      "Controller": {
        "type": "object",
        "required": [
          "id",
          "name",
          "mac",
          "model",
          "location_name",
          "online",
          "last_report_at",
          "permission"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "ctl_7Hq2mV9xLp3sK8dR"
          },
          "name": {
            "type": "string",
            "example": "Main pool"
          },
          "mac": {
            "type": "string",
            "description": "The controller's MAC address, for display only, so a technician can match the record to the device. Never use it as an identifier.",
            "example": "00:1E:C0:4A:7B:21"
          },
          "model": {
            "type": "string",
            "example": "M920"
          },
          "location_name": {
            "type": [
              "string",
              "null"
            ],
            "example": "Clubhouse"
          },
          "online": {
            "type": "boolean",
            "description": "True when the controller has reported within its expected interval."
          },
          "last_report_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "permission": {
            "$ref": "#/components/schemas/Permission"
          }
        }
      },
      "Reading": {
        "type": "object",
        "required": [
          "controller_id",
          "time"
        ],
        "description": "One report from the controller. A measurement the controller does not have is null.",
        "properties": {
          "controller_id": {
            "type": "string"
          },
          "time": {
            "type": "string",
            "format": "date-time"
          },
          "ph": {
            "type": [
              "number",
              "null"
            ],
            "example": 7.4
          },
          "orp_mv": {
            "type": [
              "integer",
              "null"
            ],
            "description": "Sanitizer ORP in millivolts.",
            "example": 720
          },
          "temperature": {
            "type": [
              "number",
              "null"
            ],
            "example": 82
          },
          "temperature_unit": {
            "type": [
              "string",
              "null"
            ],
            "enum": [
              "F",
              "C",
              null
            ]
          },
          "ppm": {
            "type": [
              "number",
              "null"
            ],
            "description": "Free chlorine or bromine, on PPM-capable models.",
            "example": 3.2
          },
          "flow": {
            "type": [
              "string",
              "null"
            ],
            "description": "Flow switch state, as the controller reports it.",
            "example": "Flow OK"
          },
          "tank1": {
            "type": [
              "string",
              "null"
            ],
            "description": "Chemical tank 1 level state.",
            "example": "Normal"
          },
          "tank2": {
            "type": [
              "string",
              "null"
            ],
            "example": "Low"
          }
        }
      },
      "Alarm": {
        "type": "object",
        "required": [
          "id",
          "controller_id",
          "type",
          "raised_at",
          "cleared_at",
          "message"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "alm_9Pl3kJ7hG1fD5sA2"
          },
          "controller_id": {
            "type": "string"
          },
          "type": {
            "type": "string",
            "enum": [
              "no_communication",
              "no_flow",
              "orp_high_low",
              "orp_over_timer",
              "ph_high_low",
              "ph_over_timer",
              "tank1",
              "tank2",
              "tank3",
              "temperature_out_of_range",
              "settings_changed",
              "other"
            ],
            "description": "`other` is an alarm type not listed here; `message` describes it."
          },
          "raised_at": {
            "type": "string",
            "format": "date-time"
          },
          "cleared_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "null while the alarm is open."
          },
          "message": {
            "type": "string",
            "example": "pH 7.9 is above the high alert 7.8."
          }
        }
      },
      "SettingsBlock": {
        "type": "object",
        "description": "One chemical channel's settings (`ph`, `orp` or `orp2`). The limits are\nthe ones the IPS app enforces:\n\n| Channel | `setpoint` | `low_alert` | `high_alert` |\n|---|---|---|---|\n| `ph` | 7.0 to 8.0, step 0.1 | 6.8 to setpoint - 0.2 | setpoint + 0.2 to 8.4 |\n| `orp` (mV) | 400 to 900, step 5 | 100 to setpoint - 20 | setpoint + 20 to 900 |\n| `orp2` (mV) | 390 to (`orp` setpoint - 10), step 5 | none | none |\n\n`orp2` has no alerts of its own; it follows `orp`'s.\n",
        "properties": {
          "setpoint": {
            "type": "number",
            "example": 7.4
          },
          "high_alert": {
            "type": [
              "number",
              "null"
            ],
            "example": 7.8
          },
          "low_alert": {
            "type": [
              "number",
              "null"
            ],
            "example": 7
          },
          "dose": {
            "type": [
              "string",
              "null"
            ],
            "description": "Feed per cycle: `\"Continuous\"`, `\"Off\"`, or one of the controller's\nfeed times, as exact strings.\n\nVP200 controllers: `\"10 sec\"`, `\"15 sec\"`, `\"20 sec\"`, `\"30 sec\"`,\n`\"45 sec\"`, `\"60 sec\"`, `\"90 sec\"`, `\"120 sec\"`, `\"150 sec\"`,\n`\"180 sec\"`, `\"210 sec\"`, `\"240 sec\"`, `\"270 sec\"`, `\"300 sec\"`,\n`\"360 sec\"`, `\"480 sec\"`, `\"540 sec\"`, `\"600 sec\"`, `\"750 sec\"`,\n`\"900 sec\"`.\n\nEvery other model: `\".6 sec\"`, `\"1.2 sec\"`, `\"2.4 sec\"`, `\"4.8 sec\"`,\n`\"6.0 sec\"`, `\"7.2 sec\"`, `\"8.4 sec\"`, `\"10.0 sec\"`, `\"15.0 sec\"`,\n`\"20.0 sec\"`, `\"30.0 sec\"`, `\"45.0 sec\"`, `\"1.0 min\"`, `\"2.0 min\"`,\n`\"3.0 min\"`, `\"4.0 min\"`, `\"5.0 min\"`, `\"8.0 min\"`, `\"10.0 min\"`,\n`\"15.0 min\"`.\n",
            "example": "30.0 sec"
          },
          "delay": {
            "type": [
              "integer",
              "null"
            ],
            "minimum": 0,
            "maximum": 99,
            "description": "Delay between dose cycles, a whole number from 0 to 99.",
            "example": 5
          },
          "overfeed": {
            "type": [
              "string",
              "null"
            ],
            "description": "Overfeed protection. With a timed dose: `\"N Cycles\"`, N from 0 to 100\nin steps of 5. With `\"Continuous\"`: `\"N Min\"`, N from 0 to 180 in\nsteps of 5. With `\"Off\"`: `\"Off\"`.\n",
            "example": "60 Cycles"
          }
        }
      },
      "Settings": {
        "type": "object",
        "required": [
          "controller_id",
          "reported_at",
          "pending_change_id"
        ],
        "properties": {
          "controller_id": {
            "type": "string"
          },
          "reported_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time",
            "description": "When the controller last reported these settings."
          },
          "pending_change_id": {
            "type": [
              "string",
              "null"
            ],
            "description": "The change waiting to be picked up by the controller, if any."
          },
          "ph": {
            "$ref": "#/components/schemas/SettingsBlock"
          },
          "orp": {
            "$ref": "#/components/schemas/SettingsBlock"
          },
          "orp2": {
            "oneOf": [
              {
                "$ref": "#/components/schemas/SettingsBlock"
              },
              {
                "type": "null"
              }
            ]
          },
          "temperature": {
            "type": [
              "object",
              "null"
            ],
            "properties": {
              "min": {
                "type": "integer"
              },
              "max": {
                "type": "integer"
              },
              "unit": {
                "type": "string",
                "enum": [
                  "F",
                  "C"
                ]
              }
            }
          }
        }
      },
      "SettingsChangeRequest": {
        "type": "object",
        "description": "Only the values you want to change. Anything omitted stays as it is.\nCross-field rules are checked on the result (your values merged over\nthe current ones), so moving a `setpoint` may also mean moving its\nalerts. Temperature `min` must not exceed `max`: 0 to 150 F, or\n-18 to 66 C on a controller that reports Celsius.\n",
        "minProperties": 1,
        "example": {
          "ph": {
            "setpoint": 7.5,
            "high_alert": 7.8
          }
        },
        "properties": {
          "ph": {
            "$ref": "#/components/schemas/SettingsBlock"
          },
          "orp": {
            "$ref": "#/components/schemas/SettingsBlock"
          },
          "orp2": {
            "$ref": "#/components/schemas/SettingsBlock"
          },
          "temperature": {
            "type": "object",
            "properties": {
              "min": {
                "type": "integer"
              },
              "max": {
                "type": "integer"
              }
            }
          }
        }
      },
      "SettingsChange": {
        "type": "object",
        "required": [
          "change_id",
          "controller_id",
          "status",
          "requested_at",
          "resolved_at",
          "requested"
        ],
        "properties": {
          "change_id": {
            "type": "string"
          },
          "controller_id": {
            "type": "string"
          },
          "status": {
            "type": "string",
            "enum": [
              "pending",
              "applied",
              "failed",
              "expired"
            ]
          },
          "requested_at": {
            "type": "string",
            "format": "date-time"
          },
          "resolved_at": {
            "type": [
              "string",
              "null"
            ],
            "format": "date-time"
          },
          "requested": {
            "$ref": "#/components/schemas/SettingsChangeRequest"
          },
          "failure_reason": {
            "type": [
              "string",
              "null"
            ],
            "description": "Set when status is failed or expired."
          }
        }
      },
      "EventType": {
        "type": "string",
        "enum": [
          "alarm.raised",
          "alarm.cleared",
          "controller.offline",
          "controller.online",
          "settings.applied",
          "settings.failed",
          "grant.revoked",
          "reading.created"
        ]
      },
      "WebhookSubscriptionCreate": {
        "type": "object",
        "required": [
          "url",
          "event_types"
        ],
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "pattern": "^https://",
            "example": "https://partner.example.com/ips/events"
          },
          "event_types": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/EventType"
            }
          }
        }
      },
      "WebhookSubscriptionUpdate": {
        "type": "object",
        "minProperties": 1,
        "properties": {
          "url": {
            "type": "string",
            "format": "uri",
            "pattern": "^https://"
          },
          "event_types": {
            "type": "array",
            "minItems": 1,
            "items": {
              "$ref": "#/components/schemas/EventType"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          }
        }
      },
      "WebhookSubscription": {
        "type": "object",
        "required": [
          "id",
          "url",
          "event_types",
          "status",
          "health",
          "created_at"
        ],
        "properties": {
          "id": {
            "type": "string",
            "example": "whk_2Nf8sQ1rT6yU4iO9"
          },
          "url": {
            "type": "string",
            "format": "uri"
          },
          "event_types": {
            "type": "array",
            "items": {
              "$ref": "#/components/schemas/EventType"
            }
          },
          "status": {
            "type": "string",
            "enum": [
              "active",
              "paused"
            ]
          },
          "health": {
            "type": "string",
            "enum": [
              "healthy",
              "unhealthy"
            ],
            "description": "Unhealthy after deliveries have kept failing for 24 hours. Undelivered events are kept and can be replayed."
          },
          "created_at": {
            "type": "string",
            "format": "date-time"
          }
        }
      },
      "EventEnvelope": {
        "type": "object",
        "required": [
          "id",
          "type",
          "occurred_at",
          "controller_id",
          "data"
        ],
        "description": "Every delivery has this shape, and carries the Standard Webhooks\nheaders `webhook-id`, `webhook-timestamp` and `webhook-signature`.\n`id` equals `webhook-id` and is the same on every retry: use it to\nignore duplicates.\n",
        "properties": {
          "id": {
            "type": "string",
            "example": "evt_3Rt8yU2iO6pA1sD4"
          },
          "type": {
            "type": "string"
          },
          "occurred_at": {
            "type": "string",
            "format": "date-time"
          },
          "controller_id": {
            "type": [
              "string",
              "null"
            ]
          },
          "data": {}
        }
      }
    }
  }
}
