Agent skill
WHOOP Webhooks Skill
Receive and verify WHOOP webhooks. Use when setting up WHOOP wearable webhook handlers, debugging X-WHOOP-Signature verification, or handling recovery.updated, workout.updated, sleep.updated and the matching .deleted events.
Install this skill
npx skills add hookdeck/webhook-skills --skill whoop-webhooks
When to Use This Skill
- How do I receive WHOOP webhooks?
- How do I verify the
X-WHOOP-Signatureheader? - Why is my WHOOP webhook signature verification failing?
- How do I handle
sleep.updated,workout.updatedorrecovery.updatedevents? - Which secret signs WHOOP webhooks?
How WHOOP Webhooks Work
WHOOP sends a POST to the webhook URL set on your app in the WHOOP Developer Dashboard. Payloads are thin notifications: { user_. Dispatch on type, then fetch the record from the v2 API (for example GET /v2/activity/sleep/{id}). For recovery.* events in v2, id is the UUID of the associated sleep.
Delivery: any non-2xx response, or no response before the timeout, counts as a failure. WHOOP retries five times over about an hour. Duplicates and missed webhooks are possible, so reconcile through the API.
Signing Secret
Paste your WHOOP app's client secret from the Developer Dashboard. WHOOP signs webhooks with it; there is no separate webhook secret.
Verification (core)
X-WHOOP-Signature is the standard padded base64 HMAC-SHA256, keyed with the client secret (UTF-8), over the X-WHOOP-Signature-Timestamp header value followed directly by the raw request body bytes. There is no separator between them. The timestamp is Unix milliseconds.
const crypto = require('crypto');
const SIG_RE = /^[A-Za-z0-9+/]{43}=$/; // 44-char padded base64 of a 32-byte digest
function verifyWhoopSignature(rawBody, signature, timestamp, secret) { // rawBody: Buffer
if (!signature || !timestamp || !secret || !SIG_RE.test(signature)) return false;
const expected = crypto
.createHmac('sha256', secret)
.update(Buffer.concat([Buffer.from(timestamp, 'latin1'), rawBody]))
.digest('base64');
return crypto.timingSafeEqual(Buffer.from(signature, 'latin1'), Buffer.from(expected, 'latin1'));
}
import base64, hashlib, hmac, re
SIG_RE = re.compile(r"[A-Za-z0-9+/]{43}=")
def verify_whoop_signature(raw_body: bytes, signature: str, timestamp: str, secret: str) -> bool:
if not signature or not timestamp or not secret or not SIG_RE.fullmatch(signature):
return False
digest = hmac.new(secret.encode(), timestamp.encode("latin-1") + raw_body, hashlib.sha256).digest()
return hmac.compare_digest(base64.b64encode(digest), signature.encode("ascii"))
Rules that matter:
- Verify the raw bytes (
express.raw({ type: '*/*' }),request.arrayBuffer(),await request.body()). Never parse and re-serialize first. - Use the header value exactly as received (no trimming or parsing). Compare the whole header as one digest, with no prefix.
- Validate the signature format before comparing. Missing, empty, malformed or mismatched signature or timestamp: respond
401. - No timestamp window by default. Hookdeck retries and replays resend the original headers, so a fixed window would reject legitimate retries. If you want replay protection, set
WHOOP_(unset orWEBHOOK_ TOLERANCE_ SECONDS 0= off); the examples then reject timestamps (milliseconds) more than that many seconds from now. Make handlers idempotent either way.
For complete handlers with tests, see examples/express/, examples/nextjs/, examples/fastapi/.
Common Event Types
| Event | Triggered When |
|---|---|
sleep.updated | A sleep is created or updated |
sleep.deleted | A sleep is deleted |
workout.updated | A workout is created or updated |
workout.deleted | A workout is deleted |
recovery.updated | A recovery is created or updated (id is the sleep UUID) |
recovery.deleted | A recovery is deleted (its sleep was deleted) |
The examples handle the three .updated events and log the rest. See references/overview.md.
Environment Variables
WHOOP_WEBHOOK_SECRET=your_whoop_client_secret # the app's OAuth client secret
WHOOP_WEBHOOK_TOLERANCE_SECONDS=0 # optional; 0 or unset = no timestamp check
Local Development
# Express and Next.js
npx hookdeck-cli listen 3000 whoop --path /webhooks/whoop
# FastAPI
npx hookdeck-cli listen 8000 whoop --path /webhooks/whoop
No account required. Use the printed Hookdeck URL as the Webhook URL in the WHOOP Developer Dashboard.
Reference Materials
- references/overview.md - What WHOOP webhooks are, events, payload
- references/setup.md - Dashboard configuration and secret
- references/verification.md - Signature details and gotchas