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-Signature header?
  • Why is my WHOOP webhook signature verification failing?
  • How do I handle sleep.updated, workout.updated or recovery.updated events?
  • 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_id, id, type, trace_id }. 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_WEBHOOK_TOLERANCE_SECONDS (unset or 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

EventTriggered When
sleep.updatedA sleep is created or updated
sleep.deletedA sleep is deleted
workout.updatedA workout is created or updated
workout.deletedA workout is deleted
recovery.updatedA recovery is created or updated (id is the sleep UUID)
recovery.deletedA 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


Repository

hookdeck/webhook-skills

v0.1.0 · MIT · Updated Nov 10, 2026

View on GitHub →