Polar 手表数据接入指南:用 AccessLink API 把训练、睡眠和恢复同步到自己的服务器

我戴的是 Polar Loop Gen 2,想把训练、睡眠和夜间恢复的数据拉到自己的服务器上,按自己的方式看。Polar 有一套官方接口叫 AccessLink API,个人开发者也能免费申请。这篇把整个接入过程从头到尾写一遍:申请 Client ID 和 Secret、OAuth 授权、注册用户、拉数据、补历史,以及我踩过的坑。最后附一个能直接跑的 Python 脚本。

先看最终效果。下面是我自己用这套接口做的私人看板:

昨晚的睡眠分期、HRV 曲线和本周训练进度
昨晚的睡眠分期、HRV 曲线和本周训练进度
训练日历:按每天训练负荷着色
训练日历:按每天训练负荷着色
训练负荷(急性 / 慢性)和每周训练量
训练负荷(急性 / 慢性)和每周训练量
单次训练详情:心率曲线、心率区间、主观感受录入
单次训练详情:心率曲线、心率区间、主观感受录入
各项指标和 7 日均线比较
各项指标和 7 日均线比较

能拿到哪些数据

数据 接口 能拿多久
训练(时长、心率、区间、负荷,可带逐秒心率) GET /v3/exercises 最近 30 天,且只有注册之后上传的
睡眠(入睡/醒来、分期、睡眠分、睡眠图) GET /v3/users/sleep 最近 28 天
夜间恢复 Nightly Recharge(HRV、夜间心率、呼吸、ANS charge) GET /v3/users/nightly-recharge 最近 28 天
每日活动(步数、卡路里) GET /v3/users/activities?from=&to= 按日期范围查
心肺负荷(strain、tolerance、比值、状态) GET /v3/users/cardio-load 默认返回最近 28 天
个人资料(体重、身高、最大心率、VO2max 等) GET /v3/users/physical-info 只有当前值,没有历史

所有接口的前缀都是 https://www.polaraccesslink.com。上面这些都是官方文档里的“非事务(non-transactional)”接口;老的“事务(transaction)”接口文档里已经标成 deprecated,新项目不用碰。

整体流程

1. 在 Polar 开发者后台创建 client      → 拿到 Client ID + Client Secret
2. 让用户在浏览器里授权(OAuth 2.0)   → 回调地址收到 code
3. 用 code + Secret 换 access token    → 同时拿到 x_user_id
4. POST /v3/users 注册这个用户          → 之后上传的训练才会推给你
5. 定时 GET 各个数据接口,存进自己的库
6. 更早的历史:用 Polar 账户的“下载数据”导出包补

第一步:申请 Client ID 和 Client Secret

这一步只需要一个 Polar Flow 账号(就是手表 App 登录用的那个),不用另外注册开发者。

  1. 打开 admin.polaraccesslink.com,用 Polar Flow 账号登录。
  2. 进入创建 client 的页面(Create client),表单分两部分。

组织信息(Organisation):组织名称、地址、城市、省/州、邮编、国家。个人使用就填自己的名字或网站名、自己的地址,不需要公司。

应用信息(Application):

字段 填什么
Application Name 应用名,授权页上会显示给用户看,比如 “xxx health”
Business contact (email) 联系邮箱
Description 用一两句话说明用途,例如:个人健康看板,只同步我自己的训练、睡眠和恢复数据,不分享给第三方
Application Web site 网站地址,要带 https://
Authorization redirect URL 最重要的一项:授权完成后 Polar 跳回的地址,例如 https://health.example.com/polar/callback

填完提交,页面上会显示这个 client 的 Client ID 和 Client Secret。马上把它们存进密码管理器或服务器的环境变量文件里。

这一步的几个注意点:

  • 回调地址要一字不差。后面授权和换 token 时传的 redirect_uri 必须和这里填的完全一样,协议、域名、路径、结尾斜杠都要对上。我第一次就把它写成了 http:://,多了一个冒号,表单也没拦。
  • 回调地址最好是 HTTPS,并且是你真正能收到请求的地址。如果暂时没有服务器,也可以先随便填一个自己的域名:授权后浏览器会跳过去,页面打不开没关系,地址栏里的 code= 照样能复制出来用(见下面的脚本)。
  • Secret 不要发给任何人,不要写进代码仓库,也不要贴到聊天里。泄露了就回后台删掉这个 client 重新建一个。

第二步:OAuth 授权,拿到 code

让用户(也就是你自己)在浏览器里打开下面这个地址:

https://flow.polar.com/oauth2/authorization?response_type=code&client_id=你的ClientID&redirect_uri=你的回调地址&state=一串随机字符

登录 Polar Flow、点同意之后,浏览器会跳到 回调地址?code=xxxx&state=一串随机字符。

  • state 是防 CSRF 用的:发起时随机生成,回调时比对是否一致,不一致就拒绝。
  • 官方文档说明:code 10 分钟内有效,只能用一次;重复使用会被拒绝,而且会把已经发出的 token 全部作废。

第三步:用 code 换 access token

curl -X POST https://polarremote.com/v2/oauth2/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Accept: application/json" \
  -d grant_type=authorization_code \
  -d code="$CODE" \
  -d redirect_uri="$REDIRECT_URI"

注意换 token 的域名是 polarremote.com,和授权页(flow.polar.com)、数据接口(polaraccesslink.com)都不一样。Client ID 和 Secret 走 HTTP Basic 认证。返回内容大致是:

{"access_token": "...", "token_type": "bearer", "expires_in": 31535999, "x_user_id": 12345678}

把 access_token 和 x_user_id 存好。文档里没有 refresh token。文档一边说 token 除非被撤销否则不会过期,一边又返回了约一年的 expires_in。稳妥的做法是:接口一旦返回 401,就提示自己重新授权一次。

第四步:注册用户

拿到 token 后,还要调用一次注册接口,Polar 才会把这个用户的数据开放给你的 client:

curl -X POST https://www.polaraccesslink.com/v3/users \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"member-id": "my-own-id-001"}'

member-id 是你自己给这个用户起的编号。返回 409 表示已经注册过,可以当成功处理。

这里有一个大坑:训练接口只返回注册之后上传到 Flow 的训练。我刚接好时调 /v3/exercises,拿到的是空列表,原因就是所有训练都在注册之前。睡眠、恢复、活动、负荷不受这个限制,能拿到最近 28 天。

第五步:拉数据

所有请求都带上 Authorization: Bearer token 和 Accept: application/json。没有数据时会返回 204(没有响应体),代码里要单独处理,别直接解析 JSON。

H="Authorization: Bearer $ACCESS_TOKEN"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/exercises?zones=true"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/exercises/训练id?samples=true"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/sleep"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/nightly-recharge"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/activities?from=2026-10-07&to=2026-11-04"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/cardio-load"
curl -H "$H" -H "Accept: application/json" "https://www.polaraccesslink.com/v3/users/physical-info"

几个解析时要知道的细节:

  • 训练:zones=true 会带上 heart_rate_zones(每个区间的上下限和停留时间,时间是 ISO 8601 格式,比如 PT4M3S)。时长 duration 也是这种格式。
  • 逐秒心率:单条训练加 samples=true,在 samples 里找 sample-type 为 "0" 的那组,就是心率(bpm)。data 是逗号分隔的字符串,recording-rate 是采样间隔(秒)。数据量很大,建议只拉一次,降采样后存起来。
  • 睡眠图 hypnogram 是“时刻 → 阶段编号”的字典。官方文档没给编号含义,我拿导出包逐条对照过:0 清醒、1 REM、2 和 3 浅睡、4 深睡、5 未识别。
  • 睡眠列表在 nights 字段里,恢复列表在 recharges 字段里,不是直接返回数组。
  • 心肺负荷:strain 和 tolerance 我用导出包验算过,分别等于最近 7 天和最近 28 天的日均训练负荷。

同步频率:数据接口只覆盖最近 28–30 天,所以至少每隔几天拉一次。我是每 6 小时拉一次,按(类型,日期或训练 id)为主键 upsert,重复拉不会产生重复数据。也可以用官方的 Webhook(POST /v3/webhooks,支持 EXERCISE、SLEEP、ACTIVITY_SUMMARY 等事件)让 Polar 主动推送;但推送连续 7 天失败会被自动停用,个人项目用定时轮询更省事。

速率限制:按注册用户数动态计算,15 分钟内 500 + 用户数 × 20 次,24 小时内 5000 + 用户数 × 100 次,超了返回 429。个人使用远远碰不到。

第六步:用导出包补历史

接口只有近一个月的数据,更早的要靠 Polar 账户的数据导出:登录 account.polar.com,找到下载数据(Download your data),等 Polar 打包好后下载一个 zip。

解压后是一堆 JSON 文件。和 Polar 帮助页的说法不同,我拿到的导出包里有睡眠和夜间恢复数据:

文件 内容
training-session_*.json 每次训练,含心率区间和逐点心率
sleep_result_*.json / sleep_score_*.json 睡眠分期和睡眠分
nightly_recovery_*.json 夜间恢复(HRV、心率、ANS)
activity-*.json 每日活动
fitness-test-results-*.json 体能测试(VO2max),接口拿不到

导出包的字段名和接口不一样,需要做一次转换。我对照同一晚的数据核对过这几个换算:

  • 夜间心率 = floor(60000 / meanNightlyRecoveryRri)
  • HRV = meanNightlyRecoveryRmssd
  • ANS charge = ansStatus
  • 睡眠阶段 WAKE / REM / NONREM1 / NONREM2 / NONREM3 对应接口编码 0 / 1 / 2 / 3 / 4
  • recoveryTimeMillis 是字符串,记得先转成数字

导入时注意去重:同一次训练可能接口里也有,按开始时间(精确到分钟)比对;并且不要让导出包覆盖接口来的数据。

完整示例脚本

下面这个脚本把前面的步骤串起来,只依赖 httpx。我用自己的账号实际跑过 pull,各个接口都正常返回。

"""Polar AccessLink 最小接入脚本:授权 → 换 token → 注册用户 → 拉数据。

准备:pip install httpx
  export POLAR_CLIENT_ID=你的ClientID
  export POLAR_CLIENT_SECRET=你的ClientSecret
  export POLAR_REDIRECT_URI=https://你的域名/polar/callback   # 必须和后台填的完全一致

  python polar_sync.py auth           # 1. 打印授权链接,浏览器打开并同意
  python polar_sync.py token <code>   # 2. 用回调地址里的 code 换 token,并注册用户
  python polar_sync.py pull           # 3. 拉最近的数据,存到 data/*.json
"""
import json
import os
import secrets
import sys
from datetime import date, timedelta
from pathlib import Path
from urllib.parse import urlencode

import httpx

CID = os.environ["POLAR_CLIENT_ID"]
SECRET = os.environ["POLAR_CLIENT_SECRET"]
REDIRECT = os.environ["POLAR_REDIRECT_URI"]
API = "https://www.polaraccesslink.com/v3"
TOKEN_FILE = Path("polar_token.json")


def auth():
    q = {"response_type": "code", "client_id": CID, "redirect_uri": REDIRECT, "state": secrets.token_urlsafe(16)}
    print("https://flow.polar.com/oauth2/authorization?" + urlencode(q))


def token(code):
    r = httpx.post(
        "https://polarremote.com/v2/oauth2/token",
        data={"grant_type": "authorization_code", "code": code, "redirect_uri": REDIRECT},
        auth=(CID, SECRET),  # HTTP Basic:client_id:client_secret
        headers={"Accept": "application/json"},
        timeout=30,
    )
    r.raise_for_status()
    tok = r.json()  # access_token, token_type, expires_in, x_user_id
    reg = httpx.post(
        f"{API}/users",
        json={"member-id": f"me-{secrets.token_hex(4)}"},
        headers={"Authorization": f"Bearer {tok['access_token']}", "Accept": "application/json"},
        timeout=30,
    )
    if reg.status_code != 409:  # 409 = 这个用户已经注册过,可以忽略
        reg.raise_for_status()
    TOKEN_FILE.write_text(json.dumps(tok))
    TOKEN_FILE.chmod(0o600)
    print("token 已保存,Polar 用户 id:", tok["x_user_id"])


def pull():
    tok = json.loads(TOKEN_FILE.read_text())
    headers = {"Authorization": f"Bearer {tok['access_token']}", "Accept": "application/json"}
    out = Path("data")
    out.mkdir(exist_ok=True)
    today = date.today()
    with httpx.Client(base_url=API, headers=headers, timeout=60) as c:
        def get(path, **params):
            r = c.get(path, params=params or None)
            if r.status_code == 204:  # 没有数据
                return None
            r.raise_for_status()
            return r.json()

        data = {
            "exercises": get("/exercises", zones="true"),
            "sleep": (get("/users/sleep") or {}).get("nights", []),
            "recharge": (get("/users/nightly-recharge") or {}).get("recharges", []),
            "activities": get("/users/activities", **{"from": (today - timedelta(days=28)).isoformat(), "to": today.isoformat()}),
            "cardio_load": get("/users/cardio-load"),
            "physical": get("/users/physical-info"),
        }
    for name, v in data.items():
        (out / f"{name}.json").write_text(json.dumps(v, ensure_ascii=False, indent=1))
        print(f"{name:12s}", len(v) if isinstance(v, list) else ("有" if v else "无"))


if __name__ == "__main__":
    cmd = sys.argv[1] if len(sys.argv) > 1 else ""
    if cmd == "auth":
        auth()
    elif cmd == "token" and len(sys.argv) == 3:
        token(sys.argv[2])
    elif cmd == "pull":
        pull()
    else:
        print(__doc__)

踩坑清单

  • 回调地址前后不一致(包括 http/https、结尾斜杠),换 token 会失败。
  • code 只能用一次,10 分钟过期;重复使用会把已发的 token 全部作废。
  • 忘了 POST /v3/users 注册,或者训练是在注册之前上传的,训练列表就是空的。
  • 没有数据时返回 204,没有响应体,直接 .json() 会报错。
  • 接口只给 28–30 天,停同步一个月就会丢数据,要靠导出包补。
  • 个人资料接口只能读、不能写,体重这些要在 Polar Flow App 里改。

参考:Polar AccessLink API v3 官方文档。

请我喝杯咖啡