# 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**.

```javascript
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'));
}

```

```python
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/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whoop-webhooks/examples/express/), [examples/nextjs/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whoop-webhooks/examples/nextjs/), [examples/fastapi/](https://github.com/hookdeck/webhook-skills/tree/main/skills/whoop-webhooks/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](https://github.com/hookdeck/webhook-skills/blob/main/skills/whoop-webhooks/references/overview.md).

## Environment Variables

```bash
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

```bash
# 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](https://github.com/hookdeck/webhook-skills/blob/main/skills/whoop-webhooks/references/overview.md) - What WHOOP webhooks are, events, payload
* [references/setup.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/whoop-webhooks/references/setup.md) - Dashboard configuration and secret
* [references/verification.md](https://github.com/hookdeck/webhook-skills/blob/main/skills/whoop-webhooks/references/verification.md) - Signature details and gotchas