# 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:

```json
{
  "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

1. Build an HTTPS endpoint that accepts `POST` requests and verifies the signature.
2. In the WHOOP Developer Dashboard, open your app's settings.
3. In the **Webhooks** section, add your URL and choose **v2** as the model version.
4. Save the app.
5. 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.

```javascript
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:

```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](/event-gateway) verifies the `X-WHOOP-Signature` header 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 `2xx` within 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](/docs/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](/docs/destinations#set-a-max-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 `2xx` for types you don't handle, as WHOOP advises.
* Route by `type` in your handler.

**How Hookdeck Can Help:** Hookdeck [filters](/docs/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](/webhooks/guides/implement-webhook-idempotency) and Hookdeck's [deduplication](/docs/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](https://hookdeck.com) sits in front of your receiver, checks every WHOOP signature, and holds each notification until your service has processed it.

[Get started with Hookdeck](https://dashboard.hookdeck.com/signup) for free and handle WHOOP webhooks reliably in minutes.