Whoop 数据采集管道:从 OAuth 到实时推送的完整方案
通过 Whoop 官方 API 和 Webhook 实现健康数据自动采集、结构化存储和实时推送的完整管道。
问题
Whoop 提供恢复分、HRV、睡眠质量等高价值健康数据,但只能在官方 App 里查看。需要一种方式自动拉取数据,用于自定义分析和跨设备整合。
好消息:Whoop 有官方开发者 API,文档清晰,标准 OAuth2。坏消息在 Garmin 篇里。
架构总览
两条数据路径:
定时拉取:
触发: 每天 21:00 cron
流程: API 拉取 → 生成快照 → 存储落盘 → 推送通知
适用: 每日汇总、周报生成
实时推送:
触发: Whoop 服务端 Webhook(睡醒/运动结束时自动触发)
流程: Webhook 接收 → 防抖 → 延迟 10 秒 → 拉取最新数据 → 推送通知
适用: 实时感知身体状态变化
认证:OAuth2 完整流程(从零到拿到 Token)
Step 1 · 创建 Whoop 应用
开发者后台: https://developer.whoop.com/console
操作:
1. 注册开发者账号
2. 创建应用
3. 记录以下凭证:
- client_id: 应用标识
- client_secret: 应用密钥
4. 配置 Redirect URL:
- 推荐用 Postman 回调: https://oauth.pstmn.io/v1/callback
- 这是已验证最稳定的方案,避免自己搭回调服务器
- 如果用自己的服务器: https://<你的域名>/callback
Step 2 · 发起 OAuth 授权(拿 code)
授权 URL 模板:
https://api.prod.whoop.com/oauth/oauth2/auth
?client_id=<WHOOP_CLIENT_ID>
&response_type=code
&redirect_uri=https://oauth.pstmn.io/v1/callback
&scope=offline read:recovery read:cycles read:sleep read:workout read:profile
&state=whoop_auth_<随机数字>
关键参数:
scope 必须包含 "offline":
- 没有 offline scope → 拿不到 refresh_token
- 没有 refresh_token → token 过期后必须人工重新授权
- 有 refresh_token → 可以无人值守自动续期
scope 建议包含 "read:cycles":
- cycles 包含 Whoop 的“周期”数据(每日循环)
- 某些端点可能需要这个权限
redirect_uri 推荐用 Postman 回调:
- 已验证可靠,避免自己搭回调服务器
- 用自己服务器回调容易因 HTTPS/域名/路径问题失败
流程:
1. 在浏览器中打开上面的 URL
2. 登录你的 Whoop 账号并授权
3. 授权成功后浏览器会跳转到 redirect_uri
4. URL 参数里会带一个 code=<AUTH_CODE>
5. 复制这个 code,有效期很短,必须立刻用
Step 3 · 用 code 换 Token
请求:
方法: POST
端点: https://api.prod.whoop.com/oauth/oauth2/token
Content-Type: application/x-www-form-urlencoded
参数:
grant_type: authorization_code
code: <上一步拿到的 AUTH_CODE>
redirect_uri: <和授权时一致的 REDIRECT_URI>
client_id: <WHOOP_CLIENT_ID>
client_secret: <WHOOP_CLIENT_SECRET>
返回示例:
access_token: "eyJhbG..." # 访问令牌
refresh_token: "abc123..." # 刷新令牌(关键!必须保存)
expires_in: 3600 # 有效秒数
token_type: "bearer"
保存:
将返回 JSON 加上 client_id、client_secret、created_at 字段
保存到本地文件(权限设为 600,仅拥有者可读)
这个文件就是后续所有 API 调用和自动刷新的基础
Token 安全规则
必须做:
- Token 文件权限 chmod 600(仅拥有者可读)
- 不要提交到 Git(加入 .gitignore)
- 不要发到群聊/聊天工具里
- refresh_token 更新后必须立刻保存覆盖文件
泄露处理:
- 立刻在 Whoop 开发者后台重置密钥
- 重新走一遍 OAuth 授权流程
Token 生命周期
access_token:
有效期: 约 1 小时(由 expires_in 字段指定)
刷新方式: 使用 refresh_token 换新
注意: 服务端可能提前吊销(即使本地计算未过期)
refresh_token:
有效期: 较长(数周到数月)
使用后: 服务端可能返回新的 refresh_token,必须保存
token 文件结构:
- access_token: 当前访问令牌
- refresh_token: 刷新令牌
- client_id: 应用 ID
- client_secret: 应用密钥
- expires_in: 有效秒数
- created_at: 创建时间(ISO 格式,注意时区是 UTC)
Token 刷新策略
正常刷新:
时机: 距过期不足 5 分钟时主动刷新
端点: https://api.prod.whoop.com/oauth/oauth2/token
参数:
grant_type: refresh_token
refresh_token: <当前 refresh_token>
client_id: <应用 ID>
client_secret: <应用密钥>
异常处理(已验证的坑):
场景: API 返回 401 但本地计算 token 未过期
原因: 凌晨 cron 多次刷新导致旧 token 被服务端提前吊销
解法: 遇到 401/403 时强制刷新 token 并重试一次
规则: 只重试一次,避免死循环
API 端点
base_url: https://api.prod.whoop.com/developer/v2/
核心端点:
恢复数据:
path: /recovery?limit=3
返回: 恢复分、静息心率、HRV RMSSD、血氧、皮温
注意: 按时间倒序,取 36 小时内最新的有效记录
睡眠数据:
path: /activity/sleep?limit=3
返回: 睡眠时长、深睡/REM/浅睡分布、效率、一致性、呼吸频率
注意: stage_summary 里的时间单位是毫秒
运动数据:
path: /activity/workout?limit=3
返回: 运动类型、时长、Strain、卡路里(单位 kilojoule,需除以 4.184 转 kcal)
注意: 只取 24 小时内的记录
认证方式:
header: Authorization: Bearer <access_token>
Webhook 实时推送
工作原理
注册方式: Whoop 开发者后台配置 Webhook URL
订阅事件:
- sleep.updated: 睡眠数据更新
- recovery.updated: 恢复分更新
- workout.updated: 运动数据更新
推送内容: 事件通知(不含完整数据,收到后需主动调 API 拉取最新数据)
接收端需要:
- 公网可达的 HTTPS 端点
- 返回 200 表示收到
重要细节:
- sleep.updated 和 recovery.updated 经常同时触发(同一次睡眠结束)
- 必须做防抖合并,否则会收到重复推送
Webhook Server 设计
核心逻辑:
1. 接收 POST /webhook
2. 保存原始 payload(调试用)
3. 防抖检查(15 秒内重复推送只处理一次)
4. 延迟 10 秒等待 Whoop 服务端数据同步
5. 调用数据拉取脚本获取最新数据
6. 生成简报 + 落盘 + 推送通知
为什么要延迟 10 秒:
Webhook 触发时数据可能还没同步到 API,立刻拉取会拿到旧数据
为什么要防抖:
Whoop 可能在短时间内连续触发多个 Webhook(同一次睡眠结束触发 sleep + recovery)
端口: 3456
路由:
- POST /webhook 或 /whoop: 接收推送
- GET /health: 健康检查
- GET /callback: OAuth 回调(用于初次授权)
公网暴露方案
方案: Cloudflare Tunnel(Zero Trust)
优势:
- 不需要开放端口
- 自动 HTTPS
- 不暴露真实 IP
配置:
域名映射: <你的域名> → http://localhost:3456
在 Tunnel 配置文件中添加 ingress 规则
Webhook URL 填写: https://<你的域名>/webhook
注意:
- 断电后 Tunnel 和 Webhook Server 都需要重启
- 建议用 LaunchAgent 或 systemd 设置自启动
数据格式:人机双读快照
格式: Markdown 正文 + HTML 注释内嵌 JSON
结构:
人类层:
- "# WHOOP — YYYY-MM-DD"
- Sleep 段:时长、深睡、REM、效率、一致性
- Recovery 段:恢复分、心率、HRV、血氧、皮温
- Workout 段:类型、时长、Strain、卡路里
- Debts 段:睡眠债、恢复债
机器层:
包裹在 "<!--WHOOP_DAILY_JSON ... WHOOP_DAILY_JSON-->" 中
包含所有字段的结构化 JSON
Agent 用正则提取: /<!--WHOOP_DAILY_JSON\s*([\s\S]*?)\s*WHOOP_DAILY_JSON-->/
关键指标计算:
实际睡眠时长: total_in_bed_time_milli - total_awake_time_milli
睡眠债: max(0, 目标睡眠时长 - 实际睡眠时长)
恢复债: max(0, 67 - 恢复分) # 67 是绿区门槛
已验证的踩坑经验
坑1_Token时区:
现象: 本地判断 token 未过期,但 API 返回 401
原因: created_at 是 UTC 时区,本地如果按本地时间计算会偏移
解法: 统一用 UTC 计算过期时间
坑2_服务端吊销:
现象: 凌晨 cron 和 Webhook 各自刷新 token,导致互相覆盖
原因: 每次刷新旧 access_token 就失效,但另一条路径还在用旧的
解法: Token 文件加锁,或统一只有一条路径刷新
坑3_Webhook重复注册:
现象: 同一事件推送了多次
原因: 开发者后台注册了多个 Webhook URL
解法: 只保留一个活跃的 Webhook URL
坑4_断电恢复:
现象: Webhook Server 停了但没人知道
解法: 设置健康检查端点(GET /health),配合定期探测
更好的解法: 用 LaunchAgent/systemd 设置自启动
坑5_推送内容与App不一致:
现象: 推送的数据看起来和 App 里显示的不一样
原因: App 的"今天"口径和 API 返回的时间口径可能不同
解法: 每条数据都附上数据时间字段(恢复: created_at,睡眠: end,运动: end)
教训: 别用"今天"这个词,用具体日期
坑6_拉取空白数据:
现象: Webhook 触发后立刻拉取,拿到的是旧数据
原因: Whoop 服务端数据同步有延迟
解法: 收到 Webhook 后延迟 10-20 秒再拉取
教训: 如果拉取后还是没数据,不要推送空白消息
坑7_Tunnel域名不匹配:
重要程度: ⭐⭐⭐⭐⭐
现象: Webhook Server 在跑、端口在监听、本地测试正常,但外部请求收不到
原因: Whoop 后台填的域名(如 whoop-callback.example.com)没有配在 Tunnel 的 ingress 规则里
解法: 确保 Tunnel 配置文件的 hostname 和 Whoop 后台的 Webhook URL 域名完全一致
教训: Tunnel 配置改动后必须交叉检查所有依赖这个 Tunnel 的服务的域名是否还在
坑8_LaunchAgent环境变量缺失:
重要程度: ⭐⭐⭐⭐
现象: 手动跑脚本正常,但 LaunchAgent 启动的服务调同一脚本报 "command not found"
原因: LaunchAgent 的 PATH 只有系统默认路径,不包含用户安装的工具(如 npm global bin)
解法: 脚本中调用外部命令用绝对路径,不要依赖 PATH
示例: 用 "/Users/xxx/.npm-global/bin/openclaw" 而不是 "openclaw"
教训: 任何被 LaunchAgent/systemd 调度的脚本,所有外部命令都必须用绝对路径
坑9_Cloudflare拦截特定路径:
重要程度: ⭐⭐⭐
现象: 本地直连 /webhook 和 /whoop 都正常,但经 Cloudflare Tunnel 后 /webhook 返回 404
原因: Cloudflare 的 WAF/安全规则可能对某些通用路径名有默认拦截
解法: 用不常见的路径名(如 /whoop)代替通用名(如 /webhook)
教训: 部署后必须从外部测试实际 URL,不能只测本地
适用场景
这套管道适用于:
- 需要自动获取 Whoop 数据用于自定义分析
- 需要实时感知身体状态变化(睡醒推送、运动结束推送)
- 需要将 Whoop 数据与其他设备(Garmin、Apple Watch)交叉分析
- 需要长期积累健康数据做趋势分析
核心优势:官方 API + Webhook 双通道,既能定时汇总,又能实时响应。