My portfolio has a new page: avivashishta.com/fitness. It shows my recovery, last night's sleep, today's strain, thirty days of trends and my last eight workouts, pulled from the WHOOP strap on my wrist. It refreshes every hour, and I can't edit the numbers. If I sleep five hours, the page says five hours.
Under the recovery number, a red pulse line moves at my real resting heart rate. At 53 bpm it beats once every 1.13 seconds, because the CSS animation duration is literally 60 / restingHeartRate.
The short version: WHOOP has a free developer API with OAuth. One server-side function fetches 30 days of recovery, cycles, sleep and workouts (about 10 requests), the page rebuilds at most once an hour, and the only thing I store is the token pair. The hard part is not the charts. It's that WHOOP refresh tokens rotate, so a serverless app has to keep them somewhere and never refresh twice at once.
Is it a good idea?
I went back and forth on this, so here's what I weighed.
Why do it. Every portfolio says "I'm consistent". This one proves it with data I don't control. It's also a real integration (OAuth, rate limits, token rotation, caching, data viz) that a recruiter or founder can click and see working, which beats a bullet point that says "worked with third-party APIs".
What I'm careful about.
- It's health data. I'm only publishing my own, and I chose to. No one else can connect a strap: the connect link only works with a secret key that lives in my env vars.
- Routine leaks. Bed and wake times say when I'm home. The page shows last night once it's over and nothing in real time, which I'm fine with. If you build your own, you can drop the times.
- The API terms. WHOOP's terms say you can't store copies of the data, can't feed it to AI models, can't use it in ads, and can't use their logo. So the page caches each fetch for an hour and stores nothing. It's kept out of my site's AI chat,
llms.txtand the Markdown versions that agents read. It also says plainly that it isn't medical advice. - Bad weeks are public. That's the point. A month of red recovery is honest content too.
How it fits together
WHOOP ──OAuth──▶ /api/whoop/callback ──▶ Redis: { access, refresh, expiresAt }
│
/fitness (ISR, revalidate 3600) ──▶ fetchWhoop(30 days) ──▶ shapeFitness() ──▶ page
Three pieces:
- A one-time connect.
/api/whoop/connect?key=…sends me to WHOOP's consent screen with the scopes I want, plusoffline. Withoutofflineyou get no refresh token, and the page dies after an hour. The callback swaps the code for tokens and saves them. - One fetch function. It reads the last 30 days from
/v2/recovery,/v2/cycle,/v2/activity/sleepand/v2/activity/workout, 25 records a page (WHOOP's maximum), followingnext_token. - An hourly page.
export const revalidate = 3600means a thousand visitors or one, WHOOP sees about 10 requests an hour. The default limit is 10,000 a day.
Concept 1: refresh tokens that rotate
This is the gotcha. Every time you refresh a WHOOP access token, you get a new refresh token and the old one stops working. On a normal server that's fine. On serverless, two functions can wake up at once, both see an expired token, both try to refresh, and the second one burns a token that is already dead. Now you're logged out and have to reconnect by hand.
So the tokens live in Redis (Upstash, from the Vercel marketplace), and refreshing takes a lock:
async function accessToken() {
const saved = await readTokens();
if (!saved) return null;
if (saved.expiresAt > Date.now() + 60_000) return saved.access;
// SET NX: only one caller gets the lock.
if ((await kv("SET", "whoop:refresh-lock", "1", "NX", "EX", "20")) !== "OK") {
await new Promise((r) => setTimeout(r, 2000)); // someone else is refreshing
const after = await readTokens();
return after && after.expiresAt > Date.now() ? after.access : null;
}
try {
// grant() saves the new pair before returning it
return (await grant({ grant_type: "refresh_token", refresh_token: saved.refresh, scope: "offline" })).access;
} finally {
await kv("DEL", "whoop:refresh-lock");
}
}
Redis holds the two tokens and an expiry time. No health data.
Concept 2: cache the page, never the tokens
In Next.js, a fetch in a server component can be cached along with the page. That is what you want for WHOOP data and exactly what you don't want for the token read: a token cached for an hour has already been rotated away. So every fetch in the WHOOP module is no-store, and the whole loader is wrapped once in unstable_cache with a one-hour lifetime:
export const revalidate = 3600;
const load = unstable_cache(async () => {
const data = await fetchWhoop(30);
return data ? shapeFitness(data) : null;
}, ["whoop-fitness"], { revalidate: 3600 });
The cached thing is the finished dashboard. If WHOOP is down during a rebuild, the loader throws, Next keeps serving the last good page, and nobody sees an error.
Concept 3: a WHOOP day starts when you fall asleep
WHOOP doesn't think in calendar days. It thinks in cycles: a cycle starts when you fall asleep and ends when you next fall asleep. A recovery belongs to a cycle and to the sleep that produced it. So to build "one row per day", I key everything off the cycle's start time, shifted to my local clock using the timezone_offset WHOOP sends with every record:
function local(iso: string, offset: string) { // offset like "+05:30"
const [, sign, hh, mm] = offset.match(/([+-])(\d\d):?(\d\d)/)!;
return new Date(Date.parse(iso) + (sign === "-" ? -1 : 1) * (+hh * 60 + +mm) * 60_000);
}
Then I lay those onto 30 fixed calendar slots, so a day without a scored recovery shows up as a gap instead of the chart quietly closing ranks.
Concept 4: compare me to me
A raw HRV of 85 means nothing on its own: everyone's normal is different. So the trend chart draws each metric over a shaded band, my own 30-day average plus or minus one standard deviation. Days above it are blue, days below it red, and for resting heart rate the colors flip, because lower is better. HRV and resting heart rate get separate panels with their own scales, because putting two units on one chart with two y-axes always lies to someone.
Every chart answers to the pointer, a tap or the arrow keys, and each has a plain table under it for screen readers.
The colors carry meaning. Recovery tiles are blue (67 to 100), yellow (34 to 66) and red (0 to 33), always with the number on the tile. Heart rate zones go from light red to deep red because they're ordered. Sleep stages get four colors I ran through a colorblind check.
Concept 5: a webhook so it updates when I wake up
The hourly rebuild means the page could be up to an hour behind. WHOOP can also call a URL whenever a sleep, recovery or workout is scored. The call doesn't carry the data, only which record changed, so my webhook does one thing: it throws away the cached dashboard, and the next visit fetches fresh numbers.
Anyone can POST to a public URL, so the webhook checks WHOOP's signature first. WHOOP signs the request timestamp plus the raw body with your app's client secret:
const expected = createHmac("sha256", process.env.WHOOP_CLIENT_SECRET!)
.update(timestamp + rawBody)
.digest();
const given = Buffer.from(req.headers.get("x-whoop-signature")!, "base64");
if (given.length !== expected.length || !timingSafeEqual(given, expected)) return unauthorized();
revalidateTag("whoop", { expire: 0 });
revalidatePath("/fitness");
Read the body with req.text(), not req.json(): the signature covers the exact bytes WHOOP sent. I also reject timestamps older than ten minutes, so a captured request can't be replayed later. Now the page usually updates a few minutes after I wake up.
The prompt
Paste this into Claude Code, Cursor or Replit to build your own. Bring your own WHOOP developer app (free at developer.whoop.com) and an Upstash Redis.
Build a public "live fitness" page for my personal site from my own WHOOP data. Next.js App Router, TypeScript, no chart library (hand-written SVG), no database except Upstash Redis over its REST API for OAuth tokens only.
OAUTH: a GET route /api/whoop/connect that only works when ?key= matches a WHOOP_CONNECT_KEY env var. It redirects to https://api.prod.whoop.com/oauth/oauth2/auth with response_type=code, my client id, redirect_uri = origin + /api/whoop/callback, scope "offline read:recovery read:cycles read:sleep read:workout read:body_measurement", and an 8 character random state saved in an HttpOnly cookie. The callback checks the state, POSTs the code (form-encoded) to https://api.prod.whoop.com/oauth/oauth2/token, saves {access, refresh, expiresAt} to Redis, revalidates the page and redirects to it.
TOKENS ROTATE: every refresh returns a new refresh token and kills the old one. Before each API call, if the access token has under a minute left, take a Redis lock (SET key 1 NX EX 20), refresh, save the new pair, release the lock. If the lock is taken, wait 2 seconds and re-read the tokens instead of refreshing again. Every fetch in this module uses cache: "no-store".
DATA: fetch the last 30 days from /developer/v2/recovery, /developer/v2/cycle, /developer/v2/activity/sleep and /developer/v2/activity/workout with limit=25 and start=ISO date, following next_token. Skip any record whose score_state isn't SCORED and drop naps. Map each cycle to a local calendar day with its timezone_offset, attach the recovery (by cycle_id) and the sleep (by the recovery's sleep_id), then fill 30 fixed day slots, leaving missing days empty.
CACHING: wrap the whole fetch-and-shape in unstable_cache with revalidate 3600 and set export const revalidate = 3600 on the page. Never store WHOOP data anywhere else. If the fetch throws at runtime, rethrow so the last good page stays up. In development with no tokens, render a deterministic sample month with a clear "sample data" banner.
PAGE: a hero with today's recovery as a big SVG ring colored by zone (67+ high, 34 to 66 medium, under 34 low, always labelled), with HRV, resting HR, hours asleep and strain as small cards around it. The ring scales 2.5% once per heartbeat, animation duration = 60 / resting heart rate seconds. Then last night: one stacked bar of awake, light, deep and REM with 2px gaps and a labelled legend, the sleep need sum (baseline + debt + strain - naps = needed) and a meter of how much I got. Then a strain gauge: three quarters of a circle from 0 to 21. Then 30 recovery tiles, then four small multiples (HRV line, resting HR line, strain bars, sleep bars), each with its own y scale and a dashed average, sharing one crosshair driven by pointer and arrow keys, plus a table view in a details element. Then the last 8 workouts with a heart rate zone bar in a single-hue ramp. Then 30-day averages.
WEBHOOK: a POST route /api/whoop/webhook. Read the raw body with req.text(). Verify the X-WHOOP-Signature header: base64 of HMAC-SHA256 over the X-WHOOP-Signature-Timestamp header value followed by the raw body, keyed with my client secret, compared with timingSafeEqual. Reject timestamps older than ten minutes. On any sleep., recovery. or workout. event, invalidate the cached dashboard and revalidate the page, then return 204.
RULES: no dual-axis charts. Respect prefers-reduced-motion. Footnote: "Data from the WHOOP API, read hourly and never stored. Not medical advice." Don't use the WHOOP logo.
Show me the token module first, then the data shaping, then the page.
What I'd add next
- Streaks. Days in a row with 7+ hours of sleep is a fun number to put on the page, and a dangerous one to lose in public.
If you build one, send it to me on Instagram. And if you want to see whether I actually slept last night, it's all on the page.