{"info":{"name":"JK Client API — Read-only Demo","schema":"https://schema.getpostman.com/json/collection/v2.1.0/collection.json","description":"Run requests in order. Set apiToken in a local Postman environment (mark it sensitive). Create it in dashboard Settings → Client API. No real token or sensor data is included. Requests only read data; successful authenticated calls update the credential last-used time. Choose an active sensor with at least six readings in the last 24 hours for the pagination demo."},"auth":{"type":"bearer","bearer":[{"key":"token","value":"{{apiToken}}","type":"string"}]},"variable":[{"key":"baseUrl","value":"https://jk.cyberlative.com","type":"string"},{"key":"sensorId","value":"","type":"string"},{"key":"from","value":"","type":"string"},{"key":"to","value":"","type":"string"}],"item":[{"name":"01 — API index (public)","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml","description":"Starts a fresh 24-hour demo window. This index is intentionally public; data requests require a token.","auth":{"type":"noauth"}},"event":[{"listen":"prerequest","script":{"type":"text/javascript","exec":["const end = new Date();","pm.collectionVariables.set(\"to\", end.toISOString());","pm.collectionVariables.set(\"from\", new Date(end.getTime() - 86400000).toISOString());","pm.collectionVariables.unset(\"nextCursor\");","pm.collectionVariables.unset(\"firstPageKeys\");"]}},{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","pm.test(\"API identifies itself\", () => {","  const body = pm.response.json();","  pm.expect(body.name).to.eql(\"JK Sensor Data API\");","  pm.expect(body.authentication.requiredForDataEndpoints).to.eql(true);","});"]}}]},{"name":"02 — Data without a token is denied","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/sensors","description":"","auth":{"type":"noauth"}},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 401\", () => pm.response.to.have.status(401));"]}}]},{"name":"03 — Discover sites","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/sites","description":""},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","pm.test(\"Sites are returned\", () => pm.expect(pm.response.json().sites).to.be.an(\"array\"));"]}}]},{"name":"04 — Discover sensors","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/sensors","description":"Automatically chooses an enabled sensor if sensorId is blank. For the client demo, set sensorId to a probe known to have recent readings."},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","pm.test(\"A configured sensor is available\", () => {","  const sensors = pm.response.json().sensors;","  pm.expect(sensors).to.be.an(\"array\").that.is.not.empty;","  if (!pm.variables.get(\"sensorId\")) {","    const sensor = sensors.find(s => s.enabled) || sensors[0];","    pm.collectionVariables.set(\"sensorId\", sensor.id);","  }","});"]}}]},{"name":"05 — Latest values","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/snapshot?sensorIds={{sensorId}}","description":"Compare tempC/rhPct with the same dashboard probe. Repeat after 30 seconds to see new timestamps when telemetry is arriving."},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","pm.test(\"Snapshot matches the chosen sensor\", () => {","  const sensors = pm.response.json().sensors;","  pm.expect(sensors).to.have.lengthOf(1);","  pm.expect(sensors[0].id).to.eql(pm.variables.get(\"sensorId\"));","  pm.expect(sensors[0]).to.include.all.keys(\"tempC\", \"rhPct\", \"ts\", \"quality\", \"isOnline\");","});"]}}]},{"name":"06 — History (first five readings)","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/readings?sensorIds={{sensorId}}&from={{from}}&to={{to}}&resolution=raw&limit=5","description":"Keep from/to unchanged for the next-page request. An empty data array can be valid; the demo precondition test then asks you to choose a sensor/period with data."},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","if (pm.response.code === 200) {","  const body = pm.response.json();","  pm.collectionVariables.set(\"nextCursor\", body.nextCursor || \"\");","  pm.collectionVariables.set(\"firstPageKeys\", JSON.stringify(body.data.map(r => r.sensor_id + \":\" + r.timestamp_utc)));","  pm.test(\"Demo sensor has history in the selected period\", () => pm.expect(body.data).to.be.an(\"array\").that.is.not.empty);","  pm.test(\"Rows match the selection and time window\", () => {","    pm.expect(body.data.length).to.be.at.most(5);","    for (const row of body.data) {","      pm.expect(row.sensor_id).to.eql(pm.variables.get(\"sensorId\"));","      pm.expect(Date.parse(row.timestamp_utc)).to.be.at.least(Date.parse(pm.variables.get(\"from\")));","      pm.expect(Date.parse(row.timestamp_utc)).to.be.below(Date.parse(pm.variables.get(\"to\")));","    }","  });","}"]}}]},{"name":"07 — History (next page, when available)","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/readings?sensorIds={{sensorId}}&from={{from}}&to={{to}}&resolution=raw&limit=5&cursor={{nextCursor}}","description":"Pagination proof is skipped when the first page has no nextCursor. Requests remain read-only."},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 200\", () => pm.response.to.have.status(200));","if (!pm.variables.get(\"nextCursor\")) {","  pm.test.skip(\"Pagination demonstration needs more than five readings\", () => {});","} else {","  pm.test(\"Next page has no repeated first-page readings\", () => {","    const first = JSON.parse(pm.variables.get(\"firstPageKeys\") || \"[]\");","    const rows = pm.response.json().data;","    pm.expect(rows).to.be.an(\"array\").that.is.not.empty;","    pm.expect(rows.length).to.be.at.most(5);","    for (const row of rows) pm.expect(first).not.to.include(row.sensor_id + \":\" + row.timestamp_utc);","  });","}"]}}]},{"name":"08 — Invalid limit is rejected","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/readings?sensorIds={{sensorId}}&from={{from}}&to={{to}}&limit=-1","description":""},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 400\", () => pm.response.to.have.status(400));","pm.test(\"Helpful validation error\", () => pm.expect(pm.response.json().error).to.include(\"limit\"));"]}}]},{"name":"09 — Reversed dates are rejected","request":{"method":"GET","header":[],"url":"{{baseUrl}}/api/v1/ml/readings?sensorIds={{sensorId}}&from={{to}}&to={{from}}","description":""},"event":[{"listen":"test","script":{"type":"text/javascript","exec":["pm.test(\"HTTP 400\", () => pm.response.to.have.status(400));","pm.test(\"Helpful date error\", () => pm.expect(pm.response.json().error).to.include(\"earlier\"));"]}}]}]}