Guide to WHOOP Webhooks: Features and Best Practices
WHOOP webhooks tell your app when a user's WHOOP data changes: a sleep is recorded or edited, a workout is logged or deleted, or a recovery score is calculated. If you build coaching, health or training products on the WHOOP API, webhooks tell you when to fetch fresh data for a user instead of polling the API for every user.
This guide covers how WHOOP webhooks are configured, the events and their IDs, how the signature uses your OAuth client secret, and how to build a receiver that copes with retries and missed webhooks.
What are WHOOP webhooks?
WHOOP webhooks are notifications for users who have authorised your app. When one of them records or changes a sleep, workout or recovery, WHOOP sends a POST with a small JSON body to each webhook URL on your app. The body says what changed and for whom, not what the new data is, so your app calls the WHOOP API to read it.
WHOOP signs each request with your app's OAuth client secret. X-WHOOP-Signature is the base64 HMAC-SHA256 of the X-WHOOP-Signature-Timestamp value immediately followed by the raw body. There's no separate webhook secret.
WHOOP webhook features
| Feature | Details |
|---|---|
| Configuration | App settings in the WHOOP Developer Dashboard, under Webhooks |
| Endpoint URL | HTTPS |
| Model version | v2 (default) or v1, per URL. v1 webhooks are no longer published |
| Event selection | None: every URL receives every event type |
| Signature header | X-WHOOP-Signature, standard base64 |
| Timestamp header | X-WHOOP-Signature-Timestamp, milliseconds since the epoch |
| Signed content | The timestamp value, then the raw body, with no separator |
| Secret | The app's OAuth client secret |
| Event field | type |
| Dedupe key | trace_id |
| Success response | Any 2xx. WHOOP recommends answering within a second |
| Retries | Five, over about an hour |
Common events
| Event | Fires when | id in v2 |
|---|---|---|
sleep.updated | A sleep is created or updated | The sleep's UUID |
sleep.deleted | A sleep is deleted | The sleep's UUID |
workout.updated | A workout is created or updated | The workout's UUID |
workout.deleted | A workout is deleted | The workout's UUID |
recovery.updated | A recovery is created or updated | The UUID of the associated sleep |
recovery.deleted | A recovery is deleted, which happens when its sleep is deleted | The UUID of the associated sleep |
There are no create events: a new sleep, workout or recovery arrives as an updated event. Cycles, day strain and body measurements have no webhooks, so read those from their API endpoints.
Here is WHOOP's v2 example body:
{
"user_id": 456,
"id": "550e8400-e29b-41d4-a716-446655440000",
"type": "sleep.updated",
"trace_id": "e369c784-5100-49e8-8098-75d35c47b31b"
}
On sleep.updated, your app calls GET /v2/activity/sleep/550e8400-e29b-41d4-a716-446655440000 with user 456's access token to read the sleep.
Setting up WHOOP webhooks
- Build an HTTPS endpoint that accepts
POSTrequests and verifies the signature. - In the WHOOP Developer Dashboard, open your app's settings.
- In the Webhooks section, add your URL and choose v2 as the model version.
- Save the app.
- Copy the app's client secret into your server's environment, for example as
WHOOP_CLIENT_SECRET.
If you run several apps, give each its own endpoint, because each app signs with its own client secret. To test, authorise your app with your own WHOOP account, then log a short activity for yesterday in the WHOOP app, or move a previous sleep's start time by a minute.
If you receive WHOOP webhooks through Hookdeck, use the Hookdeck source URL as the webhook URL and create a source with the WHOOP type. The source asks you to:
Paste your WHOOP app's client secret from the Developer Dashboard. WHOOP signs webhooks with it; there is no separate webhook secret.
The WHOOP source accepts POST only, which is how WHOOP delivers webhooks.
Securing WHOOP webhooks
Read X-WHOOP-Signature-Timestamp and X-WHOOP-Signature. Concatenate the timestamp value and the raw body bytes with nothing between them, compute an HMAC-SHA256 with your client secret, base64-encode it, and compare it with the header in constant time. Base64 is case-sensitive, so compare it exactly.
const crypto = require('crypto');
const express = require('express');
function verifyWhoopSignature(rawBody, signatureHeader, timestampHeader, clientSecret, toleranceSeconds = 0) {
if (!signatureHeader || !timestampHeader || !clientSecret) return false;
// Optional replay window. Off by default (see below). The timestamp is in milliseconds.
if (toleranceSeconds > 0) {
const sentAtMs = Number(timestampHeader);
if (!Number.isFinite(sentAtMs)) return false;
if (Math.abs(Date.now() - sentAtMs) / 1000 > toleranceSeconds) return false;
}
// The timestamp FIRST, then the raw body, with no separator.
const expected = crypto
.createHmac('sha256', clientSecret)
.update(Buffer.concat([Buffer.from(timestampHeader, 'latin1'), rawBody]))
.digest('base64');
const received = Buffer.from(signatureHeader, 'utf8');
const computed = Buffer.from(expected, 'utf8');
return received.length === computed.length && crypto.timingSafeEqual(received, computed);
}
const app = express();
app.post('/webhooks/whoop', express.raw({ type: '*/*' }), (req, res) => {
const clientSecret = process.env.WHOOP_CLIENT_SECRET;
if (!clientSecret) return res.sendStatus(500); // fail closed
const valid = verifyWhoopSignature(
req.body,
req.get('x-whoop-signature'),
req.get('x-whoop-signature-timestamp'),
clientSecret
);
if (!valid) return res.sendStatus(401);
const event = JSON.parse(req.body.toString('utf8'));
// Deduplicate on event.trace_id, then queue a fetch of event.id for event.user_id.
res.sendStatus(200);
});
The same check in Python:
import base64
import hashlib
import hmac
import time
def verify_whoop_signature(raw_body: bytes, signature_header, timestamp_header, client_secret: str,
tolerance_seconds: int = 0) -> bool:
if not signature_header or not timestamp_header or not client_secret:
return False
# Optional replay window. Off by default (see below). The timestamp is in milliseconds.
if tolerance_seconds > 0:
try:
sent_at_ms = int(timestamp_header)
except ValueError:
return False
if abs(time.time() * 1000 - sent_at_ms) / 1000 > tolerance_seconds:
return False
# The timestamp FIRST, then the raw body, with no separator.
signed = timestamp_header.encode("latin-1") + raw_body
expected = base64.b64encode(hmac.new(client_secret.encode("utf-8"), signed, hashlib.sha256).digest())
return hmac.compare_digest(signature_header.encode("utf-8"), expected)
# In FastAPI: raw_body = await request.body()
# verify_whoop_signature(raw_body, request.headers.get("x-whoop-signature"),
# request.headers.get("x-whoop-signature-timestamp"),
# os.environ["WHOOP_CLIENT_SECRET"])
WHOOP's docs don't set a timestamp tolerance, and the samples above leave the window off by default. Hookdeck applies no timestamp tolerance either, and its retries and replays resend WHOOP's original headers, so a window on your endpoint would reject late ones. If you add one, remember the timestamp is in milliseconds, not seconds.
To check your verifier, use the secret whoop-guide-client-secret, an X-WHOOP-Signature-Timestamp of 1761480000000 and this exact body:
{"user_id":456,"id":"550e8400-e29b-41d4-a716-446655440000","type":"sleep.updated","trace_id":"e369c784-5100-49e8-8098-75d35c47b31b"}
The header should be:
X-WHOOP-Signature: hI25LQ0r3G/BllwL0dsAszj9VL02H9ZLQ63FMGqs7y0=
Make WHOOP webhooks production-ready. Hookdeck Event Gateway verifies the
X-WHOOP-Signatureheader on every delivery, answers well inside WHOOP's one-second guidance, and queues each sleep, workout and recovery notification until your service has processed it.
WHOOP webhook limitations and pain points
Webhooks can be missed
The Problem: Some users' data never refreshes in your app.
Why It Happens: WHOOP retries a failed delivery five times over about an hour, then stops. Its docs also say a webhook may be missed altogether.
Workarounds:
- Return
2xxwithin a second and do the API calls from a queue. - Run a reconciliation job that reads recent sleeps, workouts and recoveries for active users, as WHOOP recommends.
How Hookdeck Can Help: Hookdeck acknowledges each delivery as soon as it arrives and stores it, then delivers to your service with retries you configure, so an outage on your side longer than an hour doesn't lose notifications. A reconciliation job is still worth having for webhooks WHOOP never sends.
The client secret is the signing key
The Problem: Verification breaks when the OAuth client secret changes, or someone who only needed webhook access now holds your OAuth credentials.
Why It Happens: WHOOP has no separate webhook secret. The same client secret signs webhooks and authenticates your app's token requests.
Workarounds:
- Store the client secret once, in a secret manager, and give your webhook receiver and OAuth code the same reference.
- When you rotate the client secret, update the webhook receiver at the same time.
How Hookdeck Can Help: With Hookdeck verifying signatures, the client secret lives on the Hookdeck source and in your OAuth service, not in every service that consumes WHOOP events.
Thin notifications and fan-out to the API
The Problem: A burst of webhooks turns into a burst of API calls and runs into rate limits.
Why It Happens: Each webhook carries only IDs. Editing one sleep, for example, sends both sleep.updated and recovery.updated, and each needs an API call to read the data.
Workarounds:
- Queue fetches and process them at a rate within WHOOP's API limits.
- Collapse repeated notifications for the same record before fetching.
How Hookdeck Can Help: Hookdeck can deliver to your service at a maximum delivery rate, which keeps the follow-up API calls inside WHOOP's limits.
No event selection
The Problem: Your endpoint receives events it doesn't use.
Why It Happens: Every webhook URL on an app receives every event type.
Workarounds:
- Return
2xxfor types you don't handle, as WHOOP advises. - Route by
typein your handler.
How Hookdeck Can Help: Hookdeck filters can drop the types you don't need, or send each type to a different destination.
Best practices
Verify against the raw body
WHOOP signs the raw HTTP body. In Express, use express.raw({ type: '*/*' }). In Next.js, use Buffer.from(await request.arrayBuffer()). In FastAPI, use await request.body().
Deduplicate on trace_id
WHOOP says you may receive several webhooks for the same event and suggests trace_id to detect them. See our guide to webhook idempotency and Hookdeck's deduplication.
Use v2 IDs with v2 endpoints
v2 webhooks carry UUIDs that match the v2 API. For recovery events, the id is the UUID of the associated sleep, so look up the recovery that belongs to that sleep rather than treating the ID as a recovery ID.
Revoke tokens for disconnected users
When a user disconnects your integration, revoke their access token. WHOOP then stops sending webhooks for that user.
Conclusion
A WHOOP webhook carries a type, an id and a user ID, signed with your OAuth client secret over the millisecond timestamp and raw body. Answer within a second, deduplicate on trace_id, fetch the data at a pace the API allows, and keep a reconciliation job running for the webhooks that never arrive.
Hookdeck Event Gateway sits in front of your receiver, checks every WHOOP signature, and holds each notification until your service has processed it.
Get started with Hookdeck for free and handle WHOOP webhooks reliably in minutes.