freeband.v1

API docs

The Freeband hub opens your data through a REST API and signed webhooks. This page covers the hub's side of the contract (freeband.v1).

All times are ISO 8601 UTC, for example 2026-10-13T20:59:41Z. Base address: https://band.ssilistre.dev

Authentication

Every request carries your personal key: Authorization: Bearer fb_.... The app creates the key when you turn on Web access. The server keeps only its SHA-256 hash, not the key itself; if you lose the key, make a new one.

export FREEBAND_TOKEN=fb_...
curl https://band.ssilistre.dev/v1/me -H "Authorization: Bearer $FREEBAND_TOKEN"

Error responses always look the same: {"error": "code", "message": "explanation"}. Common codes: 400 invalid_body or invalid_query, 401 unauthorized, 403 cross_origin, 404 not_found, 409, 413, 415, 429 rate_limited (with a Retry-After header). Messages are in Turkish for now.

The API isn't open to other websites calling it from a browser (no CORS headers). Call it from your own server, scripts or the command line.

Account

Create an account

The app does this itself when you turn on Web access. The key comes back once, only in this response. Limited to 10 requests per IP per hour.

curl -X POST https://band.ssilistre.dev/v1/accounts \
  -H "Content-Type: application/json" \
  -d '{"device_name": "Xiaomi Smart Band 10 Pro D9B9", "tz": "Europe/Istanbul"}'

{"account_id": "…", "token": "fb_…", "web_url": "https://band.ssilistre.dev/login"}

To go straight to the web panel, add #token= and the key to the end of web_url. The key stays in the URL fragment (#), so it never reaches the server; the page turns it into a session cookie and removes it from the address bar.

Account details

curl https://band.ssilistre.dev/v1/me -H "Authorization: Bearer $FREEBAND_TOKEN"

{"account_id": "…", "name": "…", "tz": "Europe/Istanbul", "created_at": "…",
 "samples": {"live": 1520, "battery": 44, "minute": 8640, "sleep": 6, "score": 6, "total": 10216},
 "devices": [{"id": "5B0E2C9A-…", "name": "Xiaomi Smart Band 10 Pro D9B9", "model": "band10pro", "last_seen": "…"}],
 "webhooks": 1}

Renew the key

Returns a new key; the old one stops working right away. Other web sessions end. Paste the new key into Settings → Web access → Enter API key in Freeband on your phone; until then the app can't send data.

curl -X POST https://band.ssilistre.dev/v1/token/rotate -H "Authorization: Bearer $FREEBAND_TOKEN"

Delete the account

Deletes the account, all samples, minute, sleep and score records, band records, webhooks and sessions for good.

curl -X DELETE https://band.ssilistre.dev/v1/account -H "Authorization: Bearer $FREEBAND_TOKEN"

Sending data

POST /v1/ingest is the endpoint the app uses. A body can be at most 512 KB and 5000 samples, and belongs to one band. Each sample is unique by (device.id, type, ts). Repeated live and battery samples are counted but not stored; a repeated minute, sleep or score replaces the old one (below). Each account can send 120 requests per hour and 200,000 samples per day.

curl -X POST https://band.ssilistre.dev/v1/ingest -H "Authorization: Bearer $FREEBAND_TOKEN" \
  -H "Content-Type: application/json" \
  --data-binary @sync.json

{"accepted": 2, "duplicates": 0}
{
  "schema": "freeband.v1",
  "event": "sync.completed",
  "device": {
    "id": "5B0E2C9A-4C7D-4F57-9A39-2D2B1D9E8A10",
    "name": "Xiaomi Smart Band 10 Pro D9B9",
    "model": "band10pro",
    "app_version": "0.4 (8)",
    "tz": "Europe/Istanbul"
  },
  "sent_at": "2026-10-13T21:00:02Z",
  "summary": { "date": "2026-10-13", "steps": 8421, "calories": 312, "heart_rate_last": 74, "battery": 46 },
  "samples": [
    { "type": "live", "ts": "2026-10-13T20:59:41Z", "steps": 8421, "calories": 312, "heart_rate": 74, "standing_hours": 9 },
    { "type": "battery", "ts": "2026-10-13T20:59:40Z", "level": 46 }
  ]
}

Validated fields: schema freeband.v1, event sync.completed or sync.partial, device.id a UUID, device.model band10 or band10pro, heart_rate 0–250, level 0–100, steps 0–200,000, calories 0–50,000, standing_hours 0–24, ts at most 2 days in the future. An account can have at most 10 bands. If device.tz is present, the account's time zone follows it.

sync.partial is sent when a sync stops halfway (say, 12 of 20 files read). The samples in the body are valid and stored; the rest come with the next sync. Webhooks get it under the same event name.

The contract only grows by adding. The hub doesn't reject samples of a type it doesn't know: it skips them and counts them in the response, {"accepted": 1, "duplicates": 0, "ignored": 2} (ignored appears only when above 0). One invalid field in a known type rejects the whole body with 400 invalid_body; the message names the field's path (e.g. samples[3].heart_rate).

Health data: minutes, sleep, scores

Once the app has parsed the band's activity files, it sends three more types. All three are overwritten: if the same (device.id, type, ts) comes again, the latest wins and the whole row is replaced. A row that arrives unchanged counts as a duplicate and doesn't trigger webhooks again. Heart rate is 30–220, SpO2 70–100 and stress 1–100; an optional field that wasn't measured is left out or null.

[
  { "type": "minute", "ts": "2026-10-13T07:42:00Z", "steps": 112, "heart_rate": 96,
    "spo2": 97, "stress": 34, "active_calories": 6, "distance_m": 84 },
  { "type": "sleep", "ts": "2026-10-12T20:41:00Z", "end": "2026-10-13T04:12:00Z", "is_nap": false,
    "minutes": { "deep": 82, "light": 241, "rem": 96, "awake": 32 },
    "wake_count": 3, "efficiency": 93,
    "stages": [[0, "light", 14], [14, "deep", 38], [52, "light", 61], [113, "rem", 22]],
    "hr_min": 48, "spo2_avg": 96.4, "spo2_min": 91, "hrv_rmssd": 42 },
  { "type": "score", "ts": "2026-10-12T21:00:00Z", "date": "2026-10-13",
    "sleep": 82, "recovery": 64, "strain": 11.4, "rhr": 54, "hrv_rmssd": 41.5,
    "confidence": { "sleep": "high", "recovery": "medium", "strain": "low" },
    "contributors": [
      { "score": "sleep", "key": "duration", "weight": 35, "value": 431, "unit": "min", "subscore": 88, "baseline": 418 },
      { "score": "recovery", "key": "hrv", "weight": 40, "value": null, "subscore": null, "note": "no RR" }
    ],
    "algo": "freeband-score/1" }
]
  • minute: ts is the start of the minute (seconds 0). steps 0–1000; optional heart_rate, spo2, stress, active_calories (0–100 kcal), distance_m (0–1000). Only non-empty minutes are sent; at most one day per body is recommended.
  • sleep: ts is bedtime, end wake time (at most 24 hours later). minutes holds minutes per stage, efficiency a percentage, stages triples of [minutes since bedtime, stage, duration] (deep, light, rem, awake). hr_min, spo2_avg and spo2_min are null when not measured; hrv_rmssd only when there is RR data. A night belongs to the day you wake up.
  • score: ts is local midnight of date. sleep and recovery 0–100, strain 0–21, each may be null. confidence is high, medium, low or none per score. contributors are the contributor rows (key required; score, weight, value, unit, subscore, baseline, note optional). algo is the algorithm version.

Reading data

Raw samples

GET /v1/samples returns live and battery samples in ascending time. Parameters: device (band ID; all bands if left out), type (live or battery), from, to, limit (default 1000, at most 5000). from and to are either ISO 8601 times (from included, to excluded) or YYYY-MM-DD days in the account's time zone (both ends included). If has_more is true, use the last sample's time as the new from.

curl "https://band.ssilistre.dev/v1/samples?type=live&from=2026-10-13&to=2026-10-13" -H "Authorization: Bearer $FREEBAND_TOKEN"

{"samples": [{"device_id": "5B0E2C9A-…", "type": "live", "ts": "2026-10-13T20:59:41Z",
  "steps": 8421, "calories": 312, "heart_rate": 74, "standing_hours": 9}],
 "has_more": false}

Minutes

GET /v1/minutes returns minutes in ascending time. Parameters: device, from, to (as for raw samples), limit (default 1440, at most 5000) and after. If has_more is true, repeat the request with after= set to the next value from the response; no row is skipped even when several bands share a minute.

curl "https://band.ssilistre.dev/v1/minutes?from=2026-10-13&to=2026-10-13" -H "Authorization: Bearer $FREEBAND_TOKEN"

{"minutes": [{"device_id": "5B0E2C9A-…", "type": "minute", "ts": "2026-10-13T07:42:00Z",
  "steps": 112, "heart_rate": 96, "spo2": 97, "stress": 34, "active_calories": 6, "distance_m": 84}],
 "has_more": true, "next": "…"}

curl "https://band.ssilistre.dev/v1/minutes?from=2026-10-13&to=2026-10-13&after=…" -H "Authorization: Bearer $FREEBAND_TOKEN"

Sleep

GET /v1/sleep returns sleep sessions. from and to apply to the wake time (end): from=2026-10-13&to=2026-10-13 gives the night from the 12th to the 13th and the naps on the 13th. limit defaults to 100, at most 1000; paging works as for minutes.

curl "https://band.ssilistre.dev/v1/sleep?from=2026-10-13&to=2026-10-13" -H "Authorization: Bearer $FREEBAND_TOKEN"

{"sleep": [{"device_id": "5B0E2C9A-…", "type": "sleep", "ts": "2026-10-12T20:41:00Z", "end": "2026-10-13T04:12:00Z",
  "is_nap": false, "minutes": {"deep": 82, "light": 241, "rem": 96, "awake": 32}, "wake_count": 3, "efficiency": 93,
  "stages": [[0, "light", 14], [14, "deep", 38]], "hr_min": 48, "spo2_avg": 96.4, "spo2_min": 91, "hrv_rmssd": 42}],
 "has_more": false, "next": null}

Scores

GET /v1/scores returns daily scores by their own date. from and to can only be YYYY-MM-DD (both ends included); without them there's no limit. limit defaults to 366, at most 1000; paging works as for minutes.

curl "https://band.ssilistre.dev/v1/scores?from=2026-10-01&to=2026-10-13" -H "Authorization: Bearer $FREEBAND_TOKEN"

{"scores": [{"device_id": "5B0E2C9A-…", "type": "score", "ts": "2026-10-12T21:00:00Z", "date": "2026-10-13",
  "sleep": 82, "recovery": 64, "strain": 11.4, "rhr": 54, "hrv_rmssd": 41.5,
  "confidence": {"sleep": "high", "recovery": "medium", "strain": "low"},
  "contributors": [{"score": "sleep", "key": "duration", "weight": 35, "value": 431, "unit": "min", "subscore": 88}],
  "algo": "freeband-score/1"}],
 "has_more": false, "next": null}

Daily summary

GET /v1/days returns one row per day and band; days follow the account's time zone. steps is the larger of the live samples' cumulative maximum and the sum of minute steps. calories comes from live samples. Heart rate summaries use live samples and minutes together and count only 30–220 bpm. When present, sleep_minutes (deep + light + REM minutes of the night ending that day), nap_minutes (naps ending that day) and score (that day's scores) are included. Days without any data aren't listed. The default range is the last 14 days including today, at most 366 days.

curl "https://band.ssilistre.dev/v1/days?from=2026-10-01&to=2026-10-13" -H "Authorization: Bearer $FREEBAND_TOKEN"

{"tz": "Europe/Istanbul", "from": "2026-10-01", "to": "2026-10-13", "days": [
  {"date": "2026-10-13", "device_id": "5B0E2C9A-…", "steps": 8421, "calories": 312,
   "heart_rate_min": 52, "heart_rate_max": 131, "heart_rate_avg": 74, "battery_last": 46,
   "sleep_minutes": 419, "nap_minutes": 35,
   "score": {"sleep": 82, "recovery": 64, "strain": 11.4, "rhr": 54, "hrv_rmssd": 41.5,
             "confidence": {"sleep": "high", "recovery": "medium", "strain": "low"}, "algo": "freeband-score/1"}}]}

Export

GET /v1/export.csv and GET /v1/export.json take the same from, to and device parameters as /v1/samples; type can be live, battery, minute, sleep or score. Without parameters you get everything. Every row carries device_id. At most 200,000 rows in total per call; split the range for more.

JSON has every type: samples (live and battery), minutes, sleep and scores arrays, in the same shape as the read endpoints. CSV is a flat table, so each type has its own file: the default CSV is live and battery samples; type=minute, type=sleep and type=score come with their own columns. Sleep stages and score contributors are quoted JSON cells in the CSV.

curl -o freeband.csv "https://band.ssilistre.dev/v1/export.csv?from=2026-10-01" -H "Authorization: Bearer $FREEBAND_TOKEN"

device_id,type,ts,steps,calories,heart_rate,standing_hours,level
5B0E2C9A-…,battery,2026-10-13T20:59:40Z,,,,,46
5B0E2C9A-…,live,2026-10-13T20:59:41Z,8421,312,74,9,

curl -o minutes.csv "https://band.ssilistre.dev/v1/export.csv?type=minute&from=2026-10-01" -H "Authorization: Bearer $FREEBAND_TOKEN"

device_id,type,ts,steps,heart_rate,spo2,stress,active_calories,distance_m
5B0E2C9A-…,minute,2026-10-13T07:42:00Z,112,96,97,34,6,84

curl -o sleep.csv "https://band.ssilistre.dev/v1/export.csv?type=sleep" -H "Authorization: Bearer $FREEBAND_TOKEN"
curl -o scores.csv "https://band.ssilistre.dev/v1/export.csv?type=score" -H "Authorization: Bearer $FREEBAND_TOKEN"
curl -o freeband.json "https://band.ssilistre.dev/v1/export.json" -H "Authorization: Bearer $FREEBAND_TOKEN"

Webhooks

After every successful upload (with at least one new or changed row), the hub POSTs the validated body to the account's webhooks with the body's event (sync.completed or sync.partial). Types the hub doesn't know aren't forwarded. The address must be https://; the hub's own domain, IP addresses and local names aren't accepted. At most 5 webhooks per account. Redirects aren't followed, the timeout is 5 seconds, and there are no retries for now. The same sample can arrive twice; the receiver should dedupe by (device.id, type, ts), keep the latest minute, sleep and score, and skip types it doesn't know.

curl -X POST https://band.ssilistre.dev/v1/webhooks -H "Authorization: Bearer $FREEBAND_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"url": "https://example.com/freeband"}'

{"id": "…", "url": "https://example.com/freeband", "secret": "fbwh_…", "created_at": "…"}

The secret comes back once, only in this response; the server keeps it encrypted and never shows it again.

curl https://band.ssilistre.dev/v1/webhooks -H "Authorization: Bearer $FREEBAND_TOKEN"
curl https://band.ssilistre.dev/v1/webhooks/ID -H "Authorization: Bearer $FREEBAND_TOKEN"
curl -X POST https://band.ssilistre.dev/v1/webhooks/ID/test -H "Authorization: Bearer $FREEBAND_TOKEN"
curl -X DELETE https://band.ssilistre.dev/v1/webhooks/ID -H "Authorization: Bearer $FREEBAND_TOKEN"

The test request sends this body with X-Freeband-Event: ping: {"schema": "freeband.v1", "event": "ping", "sent_at": "…", "webhook_id": "…"}. The response is {"delivered", "status", "error"}.

Verifying the signature

Every request comes with these headers:

  • Content-Type: application/json
  • X-Freeband-Event: sync.completed, sync.partial or ping
  • X-Freeband-Delivery: a random UUID per delivery
  • X-Freeband-Signature: sha256= and the HMAC-SHA256 of the body (lowercase hex). The key is the UTF-8 bytes of the secret.

Verify the signature before parsing the body, over the raw bytes, with a constant-time comparison.

Node.js:

import { createHmac, timingSafeEqual } from "node:crypto";
import http from "node:http";

const SECRET = process.env.FREEBAND_WEBHOOK_SECRET; // fbwh_...

http.createServer((req, res) => {
  const chunks = [];
  req.on("data", (c) => chunks.push(c));
  req.on("end", () => {
    const raw = Buffer.concat(chunks); // verify the signature over the raw body
    const expected = Buffer.from(
      "sha256=" + createHmac("sha256", SECRET).update(raw).digest("hex"),
    );
    const got = Buffer.from(req.headers["x-freeband-signature"] ?? "");
    if (got.length !== expected.length || !timingSafeEqual(got, expected)) {
      res.writeHead(401).end();
      return;
    }
    const body = JSON.parse(raw.toString("utf8"));
    if (body.event === "sync.completed" || body.event === "sync.partial") {
      // Skip types you don't know; keep the latest minute/sleep/score.
      const known = body.samples.filter((s) => ["live", "battery", "minute", "sleep", "score"].includes(s.type));
      console.log(body.device.id, body.event, known.length);
    }
    res.writeHead(204).end();
  });
}).listen(8080);

Python:

import hashlib, hmac, json, os
from http.server import BaseHTTPRequestHandler, HTTPServer

SECRET = os.environ["FREEBAND_WEBHOOK_SECRET"].encode()  # fbwh_...

class Hook(BaseHTTPRequestHandler):
    def do_POST(self):
        raw = self.rfile.read(int(self.headers.get("Content-Length", 0)))
        expected = "sha256=" + hmac.new(SECRET, raw, hashlib.sha256).hexdigest()
        got = self.headers.get("X-Freeband-Signature", "")
        if not hmac.compare_digest(got, expected):
            self.send_response(401)
            self.end_headers()
            return
        body = json.loads(raw)
        if body["event"] in ("sync.completed", "sync.partial"):
            # Skip types you don't know; keep the latest minute/sleep/score.
            known = [s for s in body["samples"] if s["type"] in ("live", "battery", "minute", "sleep", "score")]
            print(body["device"]["id"], body["event"], len(known))
        self.send_response(204)
        self.end_headers()

HTTPServer(("", 8080), Hook).serve_forever()

Web panel session

POST /v1/session with the body {"token": "fb_…"} gives a session cookie valid for 30 days (HttpOnly; Secure; SameSite=Strict). DELETE /v1/session signs out. POST /v1/session/password gives the same cookie for {"email", "password"}; POST /v1/credentials (token or session cookie) sets or changes the account's email and password (at least 10 characters). Only the panel pages can call the sign-in endpoints; requests that change data with the cookie must also come from the same origin.