Hacking Bluetooth Sports Tech for Fun and Profit — Part 4: Cracking the WHOOP Protocol With a £12 Sniffer
How a Raspberry Pi, two Nordic dongles, and a carefully-timed Bluetooth toggle revealed a protocol variant that no public documentation mentions.
The WHOOP is a fascinating device to reverse-engineer because it almost looks open. It advertises standard BLE services — Heart Rate (0x180D), Battery (0x180F), Device Information (0x180A) — but they're all dormant. The Heart Rate service is present in the GATT table but won't send a single beat until you ask nicely through the proprietary channel. And asking nicely requires BLE bonding, a specific packet framing protocol, and — as it turns out — knowing which envelope byte your particular WHOOP expects.
That last part took a sniffer to figure out.
The setup
Our BLE research lab is a Raspberry Pi 5 running a FastAPI server called VESTIGATOR. It has:
- Two BLE adapters (the Pi's built-in and an ASUS BT500)
- Two nRF52840 dongles running Nordic's Sniffer for BLE firmware
- BlueZ D-Bus integration for programmatic pairing (something macOS refuses to do)
- The whole thing accessible over Tailscale so I can SSH in from anywhere
The target: a WHOOP 4.0 that I've renamed "COR FENESTRA" — Latin for "Heart Window," because I'm that sort of person.
What the public documentation says
The WHOOP protocol has been partially documented by prior reverse-engineering — notably the whoomp project (since taken offline) and bWanShiTong's reverse-engineering write-up. Between them, they describe:
- Packet framing:
[0xAA SOF] [Length LE 2B] [CRC8 poly=0x07] [Payload] [CRC32 zlib] - Payload structure:
[PacketType 1B] [Sequence 1B] [Command 1B] [Data...] - Packet types:
0x72for commands,0x73for responses - Proprietary service UUID:
fd4b0001-cce1-4033-93ce-002d5875f58a
Armed with this, I built a command encoder, connected to the WHOOP, sent 0x72 0x00 0x0B 0x00 (GET_CLOCK), and got... silence.
No response. No error. The WHOOP accepted the write, the CRC was valid, the bonding was verified — but it simply didn't reply.
I tried every combination: write vs write-without-response, with and without the SOF framing header, bare payload bytes, different command IDs. The Events channel kept firing periodic heartbeat packets (type 0x30, cmd 0x01), proving the connection was live and the subscription was working. But the response channel stayed stubbornly silent.
The first clue: wrong UUID base
The documented proprietary service UUID is fd4b0001-cce1-4033-93ce-002d5875f58a. When I enumerated COR FENESTRA's GATT table, the proprietary service was 61080001-8d6d-82b8-614a-1c8cb0f8dcc6 — a completely different UUID base.
The prior art noted 61080001 as the "WHOOP 3.0" UUID and fd4b0001 as the "WHOOP 4.0" UUID. COR FENESTRA is definitely a WHOOP 4.0 (confirmed by the memfault channel, which helpfully leaks firmware version strings — hboylston h17.2.2.0 and gharvard i41.17 — names that look ~90% likely to be Boston-area references, since WHOOP's HQ is at 1325 Boylston Street in Boston and Harvard Avenue is just over the line in Brookline).
In fact the mapping turned out simpler than the prior art implied: 61080001 is the GEN_4 strap — the 4.0, like ours — and fd4b0001 is the later MG/5.0 hardware (spoilers, sweetie...). The "3.0/4.0" labels in the older docs were off. Either way, the characteristic suffixes are identical: 0002 for command writes, 0003 for responses, 0004 for events, 0005 for data, 0007 for memfault. The shape was the same. Only the envelope byte the docs had given us was wrong.
Enter the sniffer
When blind probing fails, you watch someone who knows how. The nRF52840 sniffer captures raw BLE link-layer traffic — including the GATT reads, writes, and notifications between a phone and the WHOOP. By sniffing the official iOS app talking to the device, I could see exactly what it sends and what comes back.
The capture setup:
- Unpair the WHOOP from everything (the WHOOP holds bonds internally — you need to use the app's "Unpair" function, not just forget it from the phone)
- Start the sniffer with
--follow-by-name 'COR FENESTRA' - Pair the phone to the WHOOP
- Let the app do its thing
This produced a 2 MB pcap with the full GATT conversation: service discovery, CCCD subscriptions, and then the proprietary command exchange. Decoded with tshark, the first command the app sends is:
APP→WHOOP: aa 10 00 57 23 04 23 aa 8e d4 69 a9 6d 00 00 00 51 30 fe f3
Breaking that down:
aa— SOF (expected)10 00— length 16 (expected)57— CRC8 (expected)23— packet type 0x23 (not 0x72!)04— sequence23— command (GET_HELLO_HARVARD)aa 8e d4 69 a9 6d 00 00 00— data (timestamp + padding)51 30 fe f3— CRC32
There it was. The packet type byte is 0x23, not 0x72. Responses come back as 0x24, not 0x73. Same framing, same CRC algorithms, same command IDs — the documented 0x72/0x73 were simply wrong (a red herring carried forward from the earlier write-ups).
The complete init sequence
The MITM capture revealed the full handshake the app performs:
| Step | Command | Purpose |
|---|---|---|
| 1 | 0x23 |
GET_HELLO_HARVARD — timestamp exchange, returns 133B device status with serial |
| 2 | 0x0A |
Clock sync |
| 3 | 0x75 |
Feature flag query (start) |
| 4 | 0x76 × 13 |
Feature flag iteration — names like general_ab_test, sigproc_10_sec_dp |
| 5 | 0x78 × 11 |
Feature flag set — enable_r19_v2_packets, enable_capsense_wear_detect, etc. |
| 6 | 0x22 |
Config query (returns 69B) |
| 7 | 0x16 |
Request historical data |
| 8 | 0x17 × N |
Historical data ack/trim loop |
The feature flag exchange is interesting, and it looks like WHOOP uses server-side A/B test configuration pushed to the device via BLE. The flag names hint at ongoing firmware experiments: enable_false_step_detection, wear_detect_bias, and similar.
First contact
With the correct packet type, everything worked immediately:
>>> HELLO_HARVARD (cmd=0x23)
<< RESP t=0x24 c=0x23 d[133] — Serial: 4A03112108...
>>> GET_BATTERY (cmd=0x1a)
<< RESP t=0x24 c=0x1a — Battery: 68.7%
>>> RT_HR_ON (cmd=0x03)
<< RESP t=0x24 c=0x03 — acknowledged
<< DATA t=0x28 — HR=61 RR=[952]
<< DATA t=0x28 — HR=61 RR=[979]
<< DATA t=0x28 — HR=63 RR=[850]
...
15 readings: min=60 avg=61 max=63
Resting heart rate streaming at ~1 Hz, with RR intervals for HRV analysis. The standard BLE Heart Rate Measurement characteristic (0x2A37) also activates when toggled via cmd=0x0E, making the WHOOP visible to any BLE heart rate consumer.
The variant map
The codebase now auto-detects which variant a WHOOP is running by checking the proprietary service UUID at connection time:
61080001→ Variant A: the GEN_4 strap (WHOOP 4.0,hboylston/gharvardfirmware)fd4b0001→ Variant B: the later straps (WHOOP MG and 5.0, which share a BLE stack, again, spoilers)
Both variants speak the same wire protocol — packet type 0x23 for commands, 0x24 for responses, identical framing, identical CRC algorithms, identical command IDs. What differs is the service UUID base and the hardware behind it, not a version byte. The 0x72/0x73 from the older documentation was never an on-wire value at all. (A separate PUFFIN power-pack puck does use its own 0x25/0x26 framing — but that's a different BLE peer, not a strap.)
The real lesson isn't that there's a secret version byte. It's that one wrong byte in the public documentation was enough to produce complete silence — until the sniffer showed what the app actually puts on the wire.
What's next
The WHOOP 4 stores historical data internally — HR, HRV, and a 93-byte record per reading containing raw sensor data we haven't fully decoded yet. That raw payload likely includes SpO2, skin temperature, accelerometer summary, and respiratory rate. A full night's recording should give enough physiological variation to correlate fields against known patterns.
This is part 4 of "Hacking Bluetooth Sports Tech for Fun and Profit" — a series documenting BLE reverse-engineering of fitness wearables. Tools: VESTIGATOR (Python + bleak), Pi Lab (FastAPI + BlueZ D-Bus), nRF52840 sniffer. All research conducted on personally-owned devices.
Comments ()