{
  "info": {
    "_postman_id": "6d80c420-82fd-4cbf-89c1-c771af54f4cc",
    "name": "DIENAS API — Public Demo",
    "description": "Distributed IoT Events, Notifications & Automation Service. Ready-to-run requests for the public climate sensor. The device sends temperature and humidity approximately every five minutes. All timestamps are ISO 8601 UTC; the trailing Z means UTC. The exact-range example automatically recalculates the start of today and tomorrow before every request. Authentication protection: more than 15 failed token attempts from the same IP within 15 minutes trigger a 15-minute IP block. HTTP 429 includes Retry-After in seconds; wait before retrying. Valid tokens do not bypass an active block.\n\nThree kinds of history:\n- Readings: GET /api/v1/measurements?device=MAC returns reported values in data. Repeated states are normal.\n- Device events: GET /api/v1/events?device=MAC returns lifecycle events in events (boot, reboot, wakeup, sleep), not door positions.\n- Relay commands: GET /api/v1/measurements?device=MAC&type=commands returns requested actions and execution status in commands. A queued ON command is not a confirmed ON state; check GET /api/v1/devices/{mac} for the latest reported state.\n\nDevice names: GET devices returns name immediately after mac. GET measurements (raw, aggregated and type=commands) returns the current name immediately after device, or null when unnamed; it is not duplicated in rows and is not a historical name snapshot.",
    "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
  },
  "auth": {
    "type": "apikey",
    "apikey": [
      {
        "key": "key",
        "value": "X-API-Token",
        "type": "string"
      },
      {
        "key": "value",
        "value": "{{api_token}}",
        "type": "string"
      },
      {
        "key": "in",
        "value": "header",
        "type": "string"
      }
    ]
  },
  "variable": [
    {"key":"contact_mac","value":"C8:47:8C:42:88:48","type":"string"},
    {
      "key": "private_relay_token",
      "value": "",
      "type": "string"
    },
    {
      "key": "relay_mac",
      "value": "AA:BB:CC:DD:EE:03",
      "type": "string"
    },
    {
      "key": "base_url",
      "value": "https://dienas.lv",
      "type": "string"
    },
    {
      "key": "api_token",
      "value": "595a321cd5eedc5f6dd461d60a574719e142b5e53bee1a0e",
      "type": "string"
    },
    {
      "key": "mac",
      "value": "78:1C:3C:21:4F:54",
      "type": "string"
    },
    {
      "key": "last_update_id",
      "value": "0",
      "type": "string"
    },
    {
      "key": "webhook_url",
      "value": "https://example.com/webhook",
      "type": "string"
    },
    {
      "key": "telegram_bot_token",
      "value": "YOUR_BOT_TOKEN",
      "type": "string"
    },
    {
      "key": "telegram_chat_id",
      "value": "YOUR_CHAT_ID",
      "type": "string"
    },
    {
      "key": "webhook_id",
      "value": "",
      "type": "string"
    },
    {
      "key": "webhook_secret",
      "value": "",
      "type": "string"
    },
    {
      "key": "today_start",
      "value": "2026-09-01T00:00:00.000Z",
      "type": "string"
    },
    {
      "key": "tomorrow_start",
      "value": "2026-09-02T00:00:00.000Z",
      "type": "string"
    }
  ],
  "event": [
    {
      "listen": "prerequest",
      "script": {
        "type": "text/javascript",
        "exec": [
          "const today = new Date();",
          "today.setUTCHours(0, 0, 0, 0);",
          "const tomorrow = new Date(today);",
          "tomorrow.setUTCDate(tomorrow.getUTCDate() + 1);",
          "pm.collectionVariables.set('today_start', today.toISOString());",
          "pm.collectionVariables.set('tomorrow_start', tomorrow.toISOString());"
        ]
      }
    },
    {
      "listen": "test",
      "script": {
        "type": "text/javascript",
        "exec": [
          "pm.test('HTTP status is successful', function () {",
          "    pm.expect(pm.response.code).to.be.within(200, 299);",
          "});",
          "pm.test('Response is JSON', function () {",
          "    pm.expect(function () { pm.response.json(); }).not.to.throw();",
          "});"
        ]
      }
    }
  ],
  "item": [
    {
      "name": "Relay control (private token required)",
      "description": "Set private_relay_token and relay_mac before use. Never connect real relays to the public demo organization. One outstanding command per device; 202 is queued, not applied. GET shows latest command, not history. Polling is approximately 10 seconds after each successful response; execution is confirmed in the following report. Expired commands stop being delivered but expiry does not switch the load off. Boot interrupts pending commands. Local controls remain independent. After an ambiguous POST failure, inspect GET before retrying. Public responses use relay_state and reported_state as \"on\"/\"off\", never 0/1. Use interval=raw for state history at all ranges; aggregation omits string states.",
      "auth": {
        "type": "apikey",
        "apikey": [
          {
            "key": "key",
            "value": "X-API-Token",
            "type": "string"
          },
          {
            "key": "value",
            "value": "{{private_relay_token}}",
            "type": "string"
          },
          {
            "key": "in",
            "value": "header",
            "type": "string"
          }
        ]
      },
      "item": [
        {
          "name": "Turn ON (expires in 60 seconds)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/devices/{{relay_mac}}",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"relay\": \"on\",\n  \"expires_in\": 60\n}"
            }
          }
        },
        {
          "name": "Get command acknowledgement and actual reported state",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/devices/{{relay_mac}}"
          }
        },
        {
          "name": "Command history — first page",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/measurements?device={{relay_mac}}&type=commands&period=7d&page=1&limit=20",
            "description": "Newest first. Each item shows the command's latest status, not individual delivery attempts. Follow pagination.has_next."
          }
        },
        {
          "name": "Command history — second page",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/measurements?device={{relay_mac}}&type=commands&period=7d&page=2&limit=20"
          }
        },
        {
          "name": "Turn OFF (after previous command finishes)",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/devices/{{relay_mac}}",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"relay\": \"off\",\n  \"expires_in\": 30\n}"
            }
          }
        },
        {
          "name": "Relay latest state and change origin",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/devices/{{relay_mac}}",
            "description": "Read latest.source: button = physical button, api = command applied and reported by the device, boot = startup OFF, unknown = older/unclassified change. Example fragment: {\"latest\":{\"source\":\"button\",\"data\":{\"relay_state\":\"on\"}}}. latest describes the last stored transition, not the latest unchanged poll. A same-state API command can become applied without changing latest.source. Button presses are not Command history entries. New firmware reports changed states and applied commands promptly (normally scheduled after 1 second); idle command polling remains approximately 10 seconds. Network/backoff/queues can delay both. applied confirms device software execution, not independent electrical feedback."
          }
        },
        {
          "name": "Relay incremental updates with sources",
          "request": {
            "method": "GET",
            "url": "{{base_url}}/api/v1/devices/{{relay_mac}}/updates?after_id=0&limit=100",
            "description": "Read updates[].source alongside data.relay_state. Values: button, api, boot, unknown. Start at after_id=0, then replace it with last_id from the preceding response. If has_more is true, request the next batch. Unchanged polls do not create updates. time is server receipt time, not physical press time; preserve returned order. No source query filter exists. Filter in your application. The current device has a finite RAM FIFO of 16 transitions plus one in-flight report; power loss/overflow can lose transitions. Pending button presses are counted separately; external input/web UI changes are sampled and may be missed between passes. Startup switches the relay OFF."
          }
        },
        {
          "name": "Relay raw history for seven days",
          "request": {
            "description": "Relay history stores first state, state changes and lifecycle events, not unchanged polls. Each raw relay reading has source next to data: button, api, boot or unknown (old/unclassified readings). Example: {\"time\":\"2026-09-21T10:00:00Z\",\"source\":\"button\",\"data\":{\"relay_state\":\"on\"}}. Time is receipt time; queued transitions may share a timestamp, so preserve response order. The device FIFO is finite and RAM-only, not a durable event log. last_seen still updates every poll. initial_state is the last stored reading before from (or null), outside data/count/pagination. Draw this state from from, then transitions; extend only to min(to,last_seen). This represents last-known state, not continuous connectivity. Historic IDs may have gaps after duplicate cleanup.",
            "method": "GET",
            "url": "{{base_url}}/api/v1/measurements?device={{relay_mac}}&period=7d&interval=raw&page=1&limit=500"
          }
        },
        {
          "name": "Webhook — relay ON and OFF",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/webhooks",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Relay ON and OFF\",\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{relay_mac}}\"\n  ],\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"relay_state\": {\n      \"eq\": \"on\"\n    }\n  }\n}"
            },
            "description": "Triggers on ON and recovers on OFF. Repeated states do not notify. Templates use {{data.relay_state}}. Use on_trigger for ON only or eq off for OFF only."
          }
        }
      ]
    },
    {
      "name": "Service",
      "item": [
        {
          "name": "Public health check",
          "auth": {
            "type": "noauth"
          },
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/health",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "health"
              ]
            },
            "description": "Public health check for the API, live MySQL query and availability_cron. Cron status: ok when last success is within 90 minutes; warning when overdue; error after a recorded failure; unknown before a successful run. next_expected_at is approximate. Cron warnings alone do not change HTTP 200. Because this request sends no token, webhook_queue is omitted."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const health = pm.response.json();",
                  "pm.test('Public health response hides queue information', function () {",
                  "    pm.expect(health.version).to.eql('4.0.0');",
                  "    pm.expect(health.checks.api.status).to.eql('ok');",
                  "    pm.expect(health.checks.database.response_time_ms).to.be.a('number');",
                  "    pm.expect(health.checks).not.to.have.property('webhook_queue');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Authenticated health check",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/health",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "health"
              ]
            },
            "description": "Uses the collection X-API-Token. webhook_queue contains only deliveries belonging to that token's organization. pending includes pending and retrying deliveries; failed counts terminal failures."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const health = pm.response.json();",
                  "pm.test('Authenticated health contains scoped queue information', function () {",
                  "    pm.expect(health.version).to.eql('4.0.0');",
                  "    pm.expect(health.checks.webhook_queue).to.have.all.keys('status', 'pending', 'failed');",
                  "    pm.expect(health.checks.webhook_queue.pending).to.be.a('number');",
                  "    pm.expect(health.checks.webhook_queue.failed).to.be.a('number');",
                  "});"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Devices",
      "item": [
        {
          "name": "Edit Contact details and timing together",
          "request": {"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{base_url}}/api/v1/devices/{{contact_mac}}","body":{"mode":"raw","raw":"{\n  \"name\": \"Entrance\",\n  \"location\": \"Ground floor\",\n  \"description\": \"Main entrance\",\n  \"settings\": {\"awake_after_change_seconds\": 15}\n}","options":{"raw":{"language":"json"}}},"description":"One atomic POST: invalid settings or metadata prevent all changes. Omitted fields stay unchanged. Device receives timing on its next connection; inspect settings.status in GET /devices/{mac}. Short z is only used in device transport; data uses awake_after_change_seconds (Contact), sleep_seconds (Climate) or poll_interval_seconds (Relay)."}
        },
        {
          "name": "Timing — Climate sleep (seconds)",
          "request": {"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{base_url}}/api/v1/devices/{{mac}}","body":{"mode":"raw","raw":"{\"settings\":{\"sleep_seconds\":600}}","options":{"raw":{"language":"json"}}},"description":"Climate only: integer 180–21600 seconds of deep sleep, plus connection time between readings. Requires updated BAT script. Saved as pending until a subsequent device report confirms the value. Settings may be combined with name, location and description in one atomic edit, but not with relay commands. This request changes the selected device's setting."}
        },
        {
          "name": "Timing — Contact inactivity window (seconds)",
          "request": {"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{base_url}}/api/v1/devices/{{contact_mac}}","body":{"mode":"raw","raw":"{\"settings\":{\"awake_after_change_seconds\":15}}","options":{"raw":{"language":"json"}}},"description":"Contact only: integer 5–300 seconds. Set contact_mac to your contact device. Changes reset the inactivity window; pending data can delay sleep. Six-hour check-in and five-minute service limit stay unchanged. Updated BAT script required; GET devices shows desired, reported and status."}
        },
        {
          "name": "Timing — Relay idle polling (seconds)",
          "request": {"method":"POST","header":[{"key":"Content-Type","value":"application/json"}],"url":"{{base_url}}/api/v1/devices/{{relay_mac}}","body":{"mode":"raw","raw":"{\"settings\":{\"poll_interval_seconds\":30}}","options":{"raw":{"language":"json"}}},"description":"Relay only: integer 5–3600 seconds. Use the token owning relay_mac. Affects idle command polling only; state-change reports and command confirmations remain prompt. Requires updated BAT script. Does not turn the relay on or off; startup remains OFF."}
        },
        {
          "name": "List devices",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/devices",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices"
              ]
            },
            "description": "Lists all devices available through the public demo token."
          }
        },
        {
          "name": "Get demo device",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "Returns the public climate sensor and its latest state."
          }
        },
        {
          "name": "Updates — initial sync and save last_id",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}/updates",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}",
                "updates"
              ]
            },
            "description": "First polling request. With no after_id, the API returns only the newest record. The test script saves response.last_id into the collection variable last_update_id."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const result = pm.response.json();",
                  "pm.collectionVariables.set('last_update_id', String(result.last_id));",
                  "pm.test('Current record and cursor returned', function () {",
                  "    pm.expect(result.count).to.eql(1);",
                  "    pm.expect(result.updates).to.have.lengthOf(1);",
                  "    pm.expect(result.last_id).to.be.a('number');",
                  "});"
                ]
              }
            }
          ]
        },
        {
          "name": "Updates — poll after saved last_id",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}/updates?after_id={{last_update_id}}&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}",
                "updates"
              ],
              "query": [
                {
                  "key": "after_id",
                  "value": "{{last_update_id}}"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Normal polling request. An empty updates array means no new records. Save the returned last_id after processing the batch; if has_more is true, request the next batch immediately."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "const result = pm.response.json();",
                  "pm.collectionVariables.set('last_update_id', String(result.last_id));"
                ]
              }
            }
          ]
        },
        {
          "name": "Updates — small backlog batch",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}/updates?after_id=0&limit=3",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}",
                "updates"
              ],
              "query": [
                {
                  "key": "after_id",
                  "value": "0"
                },
                {
                  "key": "limit",
                  "value": "3"
                }
              ]
            },
            "description": "Demonstrates batching. Starting at zero returns the oldest records for this device. With more records waiting, has_more is true; use the returned last_id as the next after_id. IDs start at 1 and increase by one independently for each organization and device."
          }
        },
        {
          "name": "Rename device — server metadata only",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Bathroom\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "Edits the device in the current token’s organization. HTTP 200: {mac, name}. Unicode, max 80 characters; null or blank clears the name. No relay command or firmware change. Send separately from relay/expires_in. GET device/list includes name. {{device.name}} is available in webhook URL templates, falling back to MAC."
          },
          "response": []
        },
        {
          "name": "Rename device — Unicode",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Коридор\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "Edits the device in the current token’s organization. HTTP 200: {mac, name}. Unicode, max 80 characters; null or blank clears the name. No relay command or firmware change. Send separately from relay/expires_in. GET device/list includes name. {{device.name}} is available in webhook URL templates, falling back to MAC."
          },
          "response": []
        },
        {
          "name": "Clear device name",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "Edits the device in the current token’s organization. HTTP 200: {mac, name}. Unicode, max 80 characters; null or blank clears the name. No relay command or firmware change. Send separately from relay/expires_in. GET device/list includes name. {{device.name}} is available in webhook URL templates, falling back to MAC."
          },
          "response": []
        },
        {
          "name": "Edit device details",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Entrance door\",\n  \"location\": \"Office · Ground floor\",\n  \"description\": \"Main entrance sensor.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "POST device metadata without operating the device. Omitted fields are preserved; null or blank clears a field. Limits: name 80, location 160, description 2000 Unicode characters. Metadata edits cannot be mixed with relay commands. GET /devices and GET /devices/{mac} return all three fields; history retains the name only."
          },
          "response": []
        },
        {
          "name": "Change only device location",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"location\": \"Warehouse\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "POST device metadata without operating the device. Omitted fields are preserved; null or blank clears a field. Limits: name 80, location 160, description 2000 Unicode characters. Metadata edits cannot be mixed with relay commands. GET /devices and GET /devices/{mac} return all three fields; history retains the name only."
          },
          "response": []
        },
        {
          "name": "Update device notes",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"description\": \"Near the entrance.\\nInstalled September 2026.\"\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "POST device metadata without operating the device. Omitted fields are preserved; null or blank clears a field. Limits: name 80, location 160, description 2000 Unicode characters. Metadata edits cannot be mixed with relay commands. GET /devices and GET /devices/{mac} return all three fields; history retains the name only."
          },
          "response": []
        },
        {
          "name": "Clear location and description",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"location\": null,\n  \"description\": null\n}",
              "options": {
                "raw": {
                  "language": "json"
                }
              }
            },
            "url": {
              "raw": "{{base_url}}/api/v1/devices/{{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "devices",
                "{{mac}}"
              ]
            },
            "description": "POST device metadata without operating the device. Omitted fields are preserved; null or blank clears a field. Limits: name 80, location 160, description 2000 Unicode characters. Metadata edits cannot be mixed with relay commands. GET /devices and GET /devices/{mac} return all three fields; history retains the name only."
          },
          "response": []
        }
      ]
    },
    {
      "name": "Measurements",
      "item": [
        {
          "name": "Raw data — first page (limit 10)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/measurements?device={{mac}}&period=24h&interval=raw&page=1&limit=10",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "measurements"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "period",
                  "value": "24h"
                },
                {
                  "key": "interval",
                  "value": "raw"
                },
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "limit",
                  "value": "10"
                }
              ]
            },
            "description": "Returns the first ten raw samples and page information."
          }
        },
        {
          "name": "Raw data — second page (limit 10)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/measurements?device={{mac}}&period=24h&interval=raw&page=2&limit=10",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "measurements"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "period",
                  "value": "24h"
                },
                {
                  "key": "interval",
                  "value": "raw"
                },
                {
                  "key": "page",
                  "value": "2"
                },
                {
                  "key": "limit",
                  "value": "10"
                }
              ]
            },
            "description": "Returns the second page of raw samples using the same range and page size."
          }
        },
        {
          "name": "Last five days — automatic aggregation",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/measurements?device={{mac}}&period=5d",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "measurements"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "period",
                  "value": "5d"
                }
              ]
            },
            "description": "Returns chart-friendly measurements for the last five days. Aggregated points preserve calculated values and report quality.status, flagged_samples, and issue occurrence counts."
          }
        },
        {
          "name": "Today to tomorrow — exact UTC range",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/measurements?device={{mac}}&from={{today_start}}&to={{tomorrow_start}}&interval=1h",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "measurements"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "from",
                  "value": "{{today_start}}",
                  "description": "Calculated automatically as 00:00:00 UTC today."
                },
                {
                  "key": "to",
                  "value": "{{tomorrow_start}}",
                  "description": "Calculated automatically as 00:00:00 UTC tomorrow."
                },
                {
                  "key": "interval",
                  "value": "1h"
                }
              ]
            },
            "description": "Demonstrates an exact from/to range. The collection pre-request script updates both dates automatically; results are grouped by hour."
          }
        },
        {
          "name": "Last three calendar months — grouped by day",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/measurements?device={{mac}}&period=3m&interval=1d",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "measurements"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "period",
                  "value": "3m"
                },
                {
                  "key": "interval",
                  "value": "1d"
                }
              ]
            },
            "description": "Demonstrates a longer relative period and daily aggregation. In period values, m means calendar months."
          }
        },
        {
          "name": "Contact history — both positions, last 30 days",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/api/v1/measurements?device=B8:06:0D:78:48:4B&period=30d&interval=raw&page=1&limit=500",
            "description": "Contact and Relay default to interval=raw at ANY time range when interval is omitted. This returns both open and closed contact_state values for a step chart. Aggregated intervals (15m, 1h, 6h, 1d) calculate numeric statistics and omit text contact states; only Climate automatically selects aggregation for longer ranges. Explicit intervals still take precedence. Battery voltage can still be aggregated, but opening/closing history requires raw. Follow pagination.has_next and increment page for all records; use fixed from/to dates for a stable export. Repeated positions are possible. Replace the demo MAC with your device."
          }
        },
        {
          "name": "Contact history — open positions",
          "request": {
            "method": "GET",
            "header": [],
            "url": "{{base_url}}/api/v1/measurements?device=AA:BB:CC:DD:EE:02&period=7d&interval=raw&contact_state=open&page=1&limit=100",
            "description": "Replace the example MAC with your contact sensor. Returns records whose data.contact_state is open, without an event property on ordinary samples. Set contact_state=closed for closed positions or omit the filter for both. Repeated states may be reported. Pagination counts only matching rows."
          }
        }
      ]
    },
    {
      "name": "Events",
      "item": [
        {
          "name": "All device events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/events?device={{mac}}&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "events"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Returns only device lifecycle events. Ordinary measurements and contact positions are available through /measurements.\nThe response includes the current device name beside device. name is null if unset and is returned even when events is empty."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  pm.test(\"Events include the device name\", () => {",
                  "    const body = pm.response.json();",
                  "    pm.expect(body).to.have.property(\"name\");",
                  "    pm.expect(body.name === null || typeof body.name === \"string\").to.eql(true);",
                  "  });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Wakeup events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/events?device={{mac}}&type=wakeup&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "events"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "type",
                  "value": "wakeup"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Returns moments when the device entered service mode.\nThe response includes the current device name beside device. name is null if unset and is returned even when events is empty."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  pm.test(\"Events include the device name\", () => {",
                  "    const body = pm.response.json();",
                  "    pm.expect(body).to.have.property(\"name\");",
                  "    pm.expect(body.name === null || typeof body.name === \"string\").to.eql(true);",
                  "  });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Sleep events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/events?device={{mac}}&type=sleep&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "events"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "type",
                  "value": "sleep"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Returns planned transitions into deep sleep.\nThe response includes the current device name beside device. name is null if unset and is returned even when events is empty."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  pm.test(\"Events include the device name\", () => {",
                  "    const body = pm.response.json();",
                  "    pm.expect(body).to.have.property(\"name\");",
                  "    pm.expect(body.name === null || typeof body.name === \"string\").to.eql(true);",
                  "  });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Power-on events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/events?device={{mac}}&type=boot&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "events"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "type",
                  "value": "boot"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Returns cold power-on events. When supplied, data.reboot_reason is 0.\nThe response includes the current device name beside device. name is null if unset and is returned even when events is empty."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  pm.test(\"Events include the device name\", () => {",
                  "    const body = pm.response.json();",
                  "    pm.expect(body).to.have.property(\"name\");",
                  "    pm.expect(body.name === null || typeof body.name === \"string\").to.eql(true);",
                  "  });",
                  "}"
                ]
              }
            }
          ]
        },
        {
          "name": "Software reboot events",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/events?device={{mac}}&type=reboot&limit=100",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "events"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                },
                {
                  "key": "type",
                  "value": "reboot"
                },
                {
                  "key": "limit",
                  "value": "100"
                }
              ]
            },
            "description": "Returns software restart events. Watchdog and crash restarts can be queried with type=watchdog and type=crash.\nThe response includes the current device name beside device. name is null if unset and is returned even when events is empty."
          },
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 200) {",
                  "  pm.test(\"Events include the device name\", () => {",
                  "    const body = pm.response.json();",
                  "    pm.expect(body).to.have.property(\"name\");",
                  "    pm.expect(body.name === null || typeof body.name === \"string\").to.eql(true);",
                  "  });",
                  "}"
                ]
              }
            }
          ]
        }
      ]
    },
    {
      "name": "Webhooks",
      "description": "Create, inspect, update, test and delete webhooks. Creation stores webhook_id and the one-time webhook_secret as collection variables. The secret does not decrypt the JSON body: receivers verify X-Webhook-Signature by calculating sha256=HMAC-SHA256(secret, X-Webhook-Timestamp + '.' + exact_raw_request_body). Use the raw body before JSON parsing, compare in constant time, and reject timestamps older than five minutes. Telegram ignores these custom signature headers; verification is intended for receiving servers you control.",
      "item": [
        {
          "name": "Create delta webhook — temperature, humidity and battery",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) { const body = pm.response.json(); pm.collectionVariables.set('webhook_id', body.id); pm.collectionVariables.set('webhook_secret', body.secret); }"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/webhooks",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Metric changes\",\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"mode\": \"on_delta\",\n  \"match\": \"any\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"delta\": 1\n    },\n    \"humidity_percent\": {\n      \"delta\": 5\n    },\n    \"battery_voltage_v\": {\n      \"delta\": 0.1\n    }\n  }\n}"
            },
            "description": "First numeric sample silently initializes each device/metric baseline. Any change of at least ±1 °C, ±5 humidity percentage points or ±0.1 V queues measurement.changed. Only triggering metrics reset their baselines. match=all requires every metric to reach its threshold in the same sample. Missing/non-numeric values do not reset baselines. Deliveries include changes with previous, current, signed delta and threshold. Configuration changes reset baselines; pause/resume preserves them."
          }
        },
        {
          "name": "Update webhook — Telegram on temperature change ±1 °C",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://api.telegram.org/bot{{telegram_bot_token}}/sendMessage?chat_id={{telegram_chat_id}}&text=Temperature:%20{{data.temperature_c}}%20C%2C%20change:%20{{delta.temperature_c}}%20C\",\n  \"mode\": \"on_delta\",\n  \"match\": \"any\",\n  \"events\": [],\n  \"conditions\": {\n    \"temperature_c\": {\n      \"delta\": 1\n    }\n  }\n}"
            },
            "description": "Set telegram_bot_token, telegram_chat_id and webhook_id first. Baseline example: 24 → 24.4 → 24.8 → 25.1 sends +1.1; then 24.0 sends -1.1. Literal sensor placeholders are expanded by the API. Do not define Postman variables named temperature_c or delta.temperature_c. Editing the conditions starts a new silent baseline."
          }
        },
        {
          "name": "More webhook recipes",
          "description": "Illustrative examples. Replace webhook_url and non-demo MAC addresses before sending. Running a request creates an enabled webhook; saving an example does not. Thresholds are examples, not device safety ratings.",
          "item": [
            {
              "name": "Offline and recovery",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"name\": \"Offline and recovery\",\n  \"url\": \"https://example.com/notify?status={{event}}\",\n  \"devices\": [],\n  \"events\": [\n    \"offline\",\n    \"online\"\n  ],\n  \"mode\": \"every_match\",\n  \"conditions\": {}\n}"
                },
                "description": "Replace destination URL before sending. One notification per transition, not hourly. Offline: hourly hosting check after over 24 hours without contact. Recovery: next successfully saved device report. Empty devices includes all organization devices. No measurement conditions. Postman also substitutes {{...}} variables: keep server-side templates literal when setting collection variables."
              }
            },
            {
              "name": "Offline only",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"https://example.com/notify?status={{event}}\",\n  \"devices\": [\n    \"78:1C:3C:21:4F:54\"\n  ],\n  \"events\": [\n    \"offline\"\n  ],\n  \"mode\": \"every_match\",\n  \"conditions\": {}\n}"
                },
                "description": "Notify once when the selected device is marked offline. No repeated hourly alert. Replace destination and device."
              }
            },
            {
              "name": "Recovery only",
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"https://example.com/notify?status={{event}}\",\n  \"devices\": [\n    \"78:1C:3C:21:4F:54\"\n  ],\n  \"events\": [\n    \"online\"\n  ],\n  \"mode\": \"every_match\",\n  \"conditions\": {}\n}"
                },
                "description": "Notify on the first saved report after a recorded offline state, not on first-ever registration or every reading. Replace destination and device."
              }
            },
            {
              "name": "Low battery and recovery",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Low battery and recovery\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"battery_voltage_v\": {\n      \"lt\": 2.2\n    }\n  }\n}"
                },
                "description": "Illustrative threshold only: 2.2 V is not a universal battery limit. Sends condition.triggered below 2.2 V and condition.recovered when voltage returns to 2.2 V or above. Choose a threshold appropriate to your hardware."
              }
            },
            {
              "name": "Temperature AND humidity",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"all\",\n  \"name\": \"Temperature AND humidity\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gte\": 28\n    },\n    \"humidity_percent\": {\n      \"gte\": 75\n    }\n  }\n}"
                },
                "description": "Both temperature ≥28 °C and humidity ≥75% must match in the same sample. One trigger when both become true; one recovery when either stops matching."
              }
            },
            {
              "name": "Humidity above 75%",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Humidity above 75%\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"humidity_percent\": {\n      \"gt\": 75\n    }\n  }\n}"
                },
                "description": "Trigger above 75%, recovery at 75% or below. For an outside-range alert, create this and the separate low-humidity webhook: two rules on the same metric are combined with AND, not OR."
              }
            },
            {
              "name": "Humidity below 40%",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Humidity below 40%\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"humidity_percent\": {\n      \"lt\": 40\n    }\n  }\n}"
                },
                "description": "Trigger below 40%, recovery at 40% or above. Use alongside the high-humidity example to monitor values outside 40–75%."
              }
            },
            {
              "name": "Door or gate opens and closes",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"AA:BB:CC:DD:EE:02\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Door or gate opens and closes\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"contact_state\": {\n      \"eq\": \"open\"\n    }\n  }\n}"
                },
                "description": "Replace the example MAC with your contact sensor. Trigger when data.contact_state becomes open; send recovery when it becomes closed. Repeated unchanged states do not notify. Use on_trigger for openings only."
              }
            },
            {
              "name": "Device restarts and crashes",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [\n    \"reboot\",\n    \"watchdog\",\n    \"crash\"\n  ],\n  \"match\": \"any\",\n  \"name\": \"Device restarts and crashes\",\n  \"mode\": \"every_match\",\n  \"conditions\": {}\n}"
                },
                "description": "Forward every reported reboot, watchdog or crash event. This cannot detect sudden power loss or silence; it only reacts to received events."
              }
            },
            {
              "name": "Forward all organization readings",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Forward all organization readings\",\n  \"mode\": \"every_match\",\n  \"conditions\": {}\n}"
                },
                "description": "An empty device list includes all devices belonging to your token. Sends every received reading, without deduplication or rate limiting. Consider destination capacity before enabling."
              }
            },
            {
              "name": "Battery changes by ±0.1 V",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Battery changes by ±0.1 V\",\n  \"mode\": \"on_delta\",\n  \"conditions\": {\n    \"battery_voltage_v\": {\n      \"delta\": 0.1\n    }\n  }\n}"
                },
                "description": "First voltage sets a silent baseline; later rises or falls of at least 0.1 V trigger. Gradual changes accumulate. Sensor noise can also trigger this rule."
              }
            },
            {
              "name": "Both temperature AND humidity change",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"all\",\n  \"name\": \"Both temperature AND humidity change\",\n  \"mode\": \"on_delta\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"delta\": 1\n    },\n    \"humidity_percent\": {\n      \"delta\": 5\n    }\n  }\n}"
                },
                "description": "First values initialize silently. Notify only when temperature has moved at least 1 °C AND humidity at least 5 percentage points from their baselines in the same sample. Both baselines then update."
              }
            },
            {
              "name": "Custom numeric metric: pressure",
              "event": [
                {
                  "listen": "test",
                  "script": {
                    "type": "text/javascript",
                    "exec": [
                      "if (pm.response.code === 201) { const w = pm.response.json(); pm.collectionVariables.set('webhook_id', w.id); pm.collectionVariables.set('webhook_secret', w.secret); }"
                    ]
                  }
                }
              ],
              "request": {
                "method": "POST",
                "header": [
                  {
                    "key": "Content-Type",
                    "value": "application/json"
                  }
                ],
                "url": "{{base_url}}/api/v1/webhooks",
                "body": {
                  "mode": "raw",
                  "raw": "{\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"AA:BB:CC:DD:EE:05\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"name\": \"Custom numeric metric: pressure\",\n  \"mode\": \"on_trigger\",\n  \"conditions\": {\n    \"pressure_kpa\": {\n      \"gt\": 120\n    }\n  }\n}"
                },
                "description": "Replace the MAC with your pressure sensor. Any numeric metric is supported; pressure_kpa must actually exist in incoming data. This illustrative rule triggers above 120 kPa and does not send recovery messages."
              }
            }
          ]
        },
        {
          "name": "Create webhook",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"High climate values\",\n  \"url\": \"{{webhook_url}}\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [],\n  \"match\": \"any\",\n  \"mode\": \"on_change\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gt\": 28\n    },\n    \"humidity_percent\": {\n      \"gt\": 75\n    }\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Creates a webhook and saves its ID and one-time HMAC signing secret. Store the secret: list and detail responses do not return it again. A receiver calculates HMAC-SHA256 over timestamp + '.' + the exact raw request body and compares it with X-Webhook-Signature. Change webhook_url to your public HTTPS receiver before running."
          }
        },
        {
          "name": "List webhooks — default (up to 100)",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "No page parameters are needed. Pagination is returned only if more than 100 webhooks exist."
          }
        },
        {
          "name": "List webhooks — explicit page",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks?page=1&limit=20",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ],
              "query": [
                {
                  "key": "page",
                  "value": "1"
                },
                {
                  "key": "limit",
                  "value": "20"
                }
              ]
            },
            "description": "Shows page metadata even when all results fit on one page."
          }
        },
        {
          "name": "List webhooks — selected device",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks?device={{mac}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ],
              "query": [
                {
                  "key": "device",
                  "value": "{{mac}}"
                }
              ]
            },
            "description": "Returns webhooks assigned to mac and organization-wide webhooks whose devices array is empty. Add page and limit when pagination is needed."
          }
        },
        {
          "name": "Get webhook",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            }
          }
        },
        {
          "name": "Pause webhook",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": false\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Updates only enabled and keeps every other webhook setting unchanged."
          }
        },
        {
          "name": "Resume webhook",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"enabled\": true\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Resumes delivery while keeping the URL, filters, and conditions unchanged."
          }
        },
        {
          "name": "Change temperature threshold",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gt\": 30\n    }\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Replaces the complete conditions object. Other webhook fields remain unchanged."
          }
        },
        {
          "name": "Change devices and events",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"events\": [\n    \"boot\",\n    \"reboot\",\n    \"wakeup\",\n    \"sleep\"\n  ]\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Replaces the device and event filters in one update. Use an empty devices array to accept every device."
          }
        },
        {
          "name": "Change destination URL",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"{{webhook_url}}\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Changes only the HTTPS destination URL."
          }
        },
        {
          "name": "Configure Telegram message with live values",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://api.telegram.org/bot{{telegram_bot_token}}/sendMessage?chat_id={{telegram_chat_id}}&text=Temperature:%20{{data.temperature_c}}%20C%2C%20humidity:%20{{data.humidity_percent}}%25\",\n  \"mode\": \"on_trigger\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gt\": 28\n    }\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Stores a URL template with literal {{data.temperature_c}} and {{data.humidity_percent}} placeholders. Set telegram_bot_token and telegram_chat_id in Postman, but do not define sensor-placeholder variables: the API substitutes those during delivery."
          }
        },
        {
          "name": "Change several webhook settings",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"name\": \"Critical climate alert\",\n  \"match\": \"all\",\n  \"mode\": \"on_trigger\",\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gte\": 30\n    },\n    \"humidity_percent\": {\n      \"gt\": 80\n    }\n  },\n  \"enabled\": true\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Demonstrates changing several fields in one request. Fields not included in the body are preserved."
          }
        },
        {
          "name": "Send test webhook",
          "request": {
            "method": "POST",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}/test",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}",
                "test"
              ]
            },
            "description": "Queues a webhook.test delivery. Enable the webhook before using this request."
          }
        },
        {
          "name": "List delivery attempts",
          "request": {
            "method": "GET",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}/deliveries",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}",
                "deliveries"
              ]
            },
            "description": "Returns delivery status, retry count, HTTP status and any error. Add page and limit only when needed. Each delivery includes method: GET or POST, recorded for the latest attempt. null means no method was recorded (including older history or not-yet-sent deliveries). Changing webhook settings does not rewrite completed deliveries. response_body, response_time_ms and response_truncated describe the latest attempt, including HTTP errors. Body capture is limited to 8192 bytes; older history has null values. Response text can contain sensitive receiver data. No response headers are stored."
          }
        },
        {
          "name": "Delete webhook",
          "request": {
            "method": "DELETE",
            "header": [],
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Deletes the webhook and cancels pending deliveries. No request body is required."
          }
        },
        {
          "name": "Use ordinary measurements instead of lifecycle events",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "url": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
            "body": {
              "mode": "raw",
              "raw": "{\n  \"events\": []\n}"
            },
            "description": "Empty events selects ordinary measurements, including contact position. A non-empty array selects only listed lifecycle events. Omitting events on update preserves the existing filter."
          }
        },
        {
          "name": "Create webhook — POST JSON",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com/webhook\",\n  \"method\": \"POST\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gt\": 25\n    }\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Delivery method: POST sends standard event JSON; GET sends no body. Both support URL placeholders. Existing webhooks default to POST. This API configuration request always uses POST."
          }
        },
        {
          "name": "Create webhook — GET URL parameters",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com/notify?device={{device.mac}}&temperature={{data.temperature_c}}\",\n  \"method\": \"GET\",\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"conditions\": {\n    \"temperature_c\": {\n      \"gt\": 25\n    }\n  }\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Delivery method: POST sends standard event JSON; GET sends no body. Both support URL placeholders. Existing webhooks default to POST. This API configuration request always uses POST."
          }
        },
        {
          "name": "Change delivery method to POST",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"method\": \"POST\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Changes only the outgoing delivery method. Omitted settings remain unchanged. Pending deliveries use the current method at send time."
          }
        },
        {
          "name": "Custom POST body — typed temperature",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com/webhook\",\n  \"method\": \"POST\",\n  \"body\": {\n    \"device\": \"{{device.mac}}\",\n    \"temperature\": \"{{data.temperature_c}}\",\n    \"message\": \"Temperature changed\"\n  },\n  \"devices\": [\n    \"{{mac}}\"\n  ],\n  \"conditions\": {\n    \"temperature_c\": {\n      \"delta\": 1\n    }\n  },\n  \"mode\": \"on_delta\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Custom body replaces standard JSON. Whole-value placeholders preserve types; missing values become null (empty text when embedded). Only POST supports a body. Body values are JSON, not URL-encoded. If Postman has variables with these placeholder names, ensure they do not replace the literal webhook templates before sending."
          }
        },
        {
          "name": "Telegram — custom POST JSON",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://api.telegram.org/botYOUR_BOT_TOKEN/sendMessage\",\n  \"method\": \"POST\",\n  \"body\": {\n    \"chat_id\": \"YOUR_CHAT_ID\",\n    \"text\": \"Door: {{data.contact_state}}\\nDevice: {{device.mac}}\"\n  },\n  \"devices\": [\n    \"C8:47:8C:42:88:48\"\n  ],\n  \"conditions\": {\n    \"contact_state\": {\n      \"eq\": \"open\"\n    }\n  },\n  \"mode\": \"on_change\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Custom body replaces standard JSON. Whole-value placeholders preserve types; missing values become null (empty text when embedded). Only POST supports a body. Body values are JSON, not URL-encoded. If Postman has variables with these placeholder names, ensure they do not replace the literal webhook templates before sending."
          }
        },
        {
          "name": "Restore standard POST body",
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"body\": null\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks/{{webhook_id}}",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks",
                "{{webhook_id}}"
              ]
            },
            "description": "Clear the custom template; future POST deliveries use the standard event JSON."
          }
        },
        {
          "name": "Custom body — device location and description",
          "event": [
            {
              "listen": "test",
              "script": {
                "type": "text/javascript",
                "exec": [
                  "if (pm.response.code === 201) {",
                  "    const body = pm.response.json();",
                  "    pm.collectionVariables.set('webhook_id', body.id);",
                  "    pm.collectionVariables.set('webhook_secret', body.secret);",
                  "}"
                ]
              }
            }
          ],
          "request": {
            "method": "POST",
            "header": [
              {
                "key": "Content-Type",
                "value": "application/json"
              }
            ],
            "body": {
              "mode": "raw",
              "raw": "{\n  \"url\": \"https://example.com/webhook\",\n  \"method\": \"POST\",\n  \"body\": {\n    \"room\": \"{{device.location}}\",\n    \"notes\": \"{{device.description}}\",\n    \"message\": \"Door at {{device.location}}: {{data.contact_state}}\"\n  },\n  \"devices\": [\n    \"C8:47:8C:42:88:48\"\n  ],\n  \"conditions\": {\n    \"contact_state\": {\n      \"eq\": \"open\"\n    }\n  },\n  \"mode\": \"on_change\"\n}"
            },
            "url": {
              "raw": "{{base_url}}/api/v1/webhooks",
              "host": [
                "{{base_url}}"
              ],
              "path": [
                "api",
                "v1",
                "webhooks"
              ]
            },
            "description": "Device metadata placeholders work in URLs and POST body values. Values are captured when queued. Missing values become null as whole JSON placeholders or empty strings in text/URLs. URL values are encoded automatically. Test deliveries use example metadata."
          }
        }
      ]
    }
  ]
}
