Skip to content
AF
Free Tool

A starter project for your fitness API integration

Pick a stack (Node.js + Express, Next.js route handlers or Python + FastAPI) and the vendors you integrate (Oura, WHOOP, Polar AccessLink, Strava and Wahoo), and download a project skeleton as a .zip built in your browser: an OAuth start and callback route per vendor with a state check and PKCE where the vendor documents it, a token-store interface with an in-memory stand-in, a webhook endpoint that answers each vendor’s handshake and checks its documented signature or token, a .env.example and a README. Every endpoint and header comes from a page this site already sources, and every combination is compiled and run in the site’s tests.

Stack
Vendors
9 files, built in your browser. Nothing is uploaded.
# fitness-integration

A Node.js + Express skeleton for Oura, WHOOP, generated by https://aifitnessapi.com/tools/starter-scaffold on the facts the site had verified as of 2026-11-05. Every endpoint, header and signing rule below links the page that sources it; re-check them before you ship, because vendors change.

## Run it

```sh
npm install
cp .env.example .env   # fill it in
npm start   # http://localhost:3000
```

## Routes

| Route | What it does |
|---|---|
| `GET /oauth/{vendor}/start?user=<your user id>` | Redirects to the vendor's authorize URL with a state (and a PKCE challenge where the vendor documents PKCE) |
| `GET /oauth/{vendor}/callback` | Checks state, exchanges the code, saves the grant through the token store |
| `GET/HEAD/POST /webhooks/{vendor}` | Answers the vendor's handshake, checks its signature or token, acknowledges fast and hands the event to `handleEvent` |

## Per vendor

### Oura

- Authorize: `https://cloud.ouraring.com/oauth/authorize`; token: `https://api.ouraring.com/oauth/token` (client credentials in the form body).
- Oura marks PKCE as recommended; the skeleton sends an S256 code_challenge and the code_verifier on the exchange.
- Oura's refresh token is single-use: store the new pair after every refresh.
- Webhooks: HMAC-SHA256, keyed by your client secret, over the x-oura-timestamp value followed by JSON.stringify(body); hex, upper-case, in x-oura-signature.
- Respond quickly (under 10 seconds) with a 2xx status, and process the event asynchronously.
- Oura retries failed deliveries 10 times each for 4xx responses, 5xx responses and timeouts, for about an hour.
- A 410 response cancels the subscription.
- Sources: https://aifitnessapi.com/integrate/oura-api and https://cloud.ouraring.com/v2/static/json/openapi-1.43.json

### WHOOP

- Authorize: `https://api.prod.whoop.com/oauth/oauth2/auth`; token: `https://api.prod.whoop.com/oauth/oauth2/token` (client credentials in the form body).
- WHOOP issues a refresh token only when you request the offline scope.
- WHOOP says a state you generate must be eight characters long.
- A refresh invalidates the previous tokens, and two simultaneous refreshes for one member fail: refresh on a schedule, one at a time.
- Webhooks: HMAC-SHA256, keyed by your app's client secret, over the X-WHOOP-Signature-Timestamp value followed by the raw body; base64, in X-WHOOP-Signature.
- Return a successful 2XX; WHOOP recommends doing so within a second and processing asynchronously.
- WHOOP retries failed deliveries five times over about one hour; anything other than a 2XX, or no response before timing out, counts as failed.
- Duplicates are possible: use trace_id to detect them.
- Sources: https://aifitnessapi.com/integrate/whoop-api and https://developer.whoop.com/docs/developing/webhooks/

## Before production

- **Replace the in-memory stores.** `TokenStore` and the OAuth state store keep data in process memory so the skeleton runs with no database. Implement the interface against your database, and encrypt tokens at rest: https://aifitnessapi.com/cookbook/token-vault.
- **Wire `user` to your own auth.** The start route takes the user id as a query parameter only so the skeleton is self-contained.
- **Make `handleEvent` enqueue, not fetch.** Acknowledge first; fetch the record the event points to from a worker: https://aifitnessapi.com/cookbook/webhook-receiver.
- **Refresh tokens one at a time per user**, and store every rotated refresh token: https://aifitnessapi.com/cookbook/refresh-rotation.

Checked by: node --check, and the test starts the server.

What each vendor gets

As the site had them on 2026-11-05. The OAuth details are from each vendor’s integration guide; the webhook rules are the ones the webhook verifier quotes from the vendor’s own page.

Oura

HMAC signature
Authorize
https://cloud.ouraring.com/oauth/authorize
Token
https://api.ouraring.com/oauth/token (credentials in the form body)
Default scopes
daily heartrate workout spo2
PKCE
S256, as the vendor documents
Webhooks
HMAC-SHA256, keyed by your client secret, over the x-oura-timestamp value followed by JSON.stringify(body); hex, upper-case, in x-oura-signature.
  • Oura marks PKCE as recommended; the skeleton sends an S256 code_challenge and the code_verifier on the exchange.
  • Oura's refresh token is single-use: store the new pair after every refresh.

Sources: Oura integration guide and Oura’s webhook documentation.

WHOOP

HMAC signature
Authorize
https://api.prod.whoop.com/oauth/oauth2/auth
Token
https://api.prod.whoop.com/oauth/oauth2/token (credentials in the form body)
Default scopes
read:recovery read:sleep read:workout offline
PKCE
not sent: the guide does not document it
Webhooks
HMAC-SHA256, keyed by your app's client secret, over the X-WHOOP-Signature-Timestamp value followed by the raw body; base64, in X-WHOOP-Signature.
  • WHOOP issues a refresh token only when you request the offline scope.
  • WHOOP says a state you generate must be eight characters long.
  • A refresh invalidates the previous tokens, and two simultaneous refreshes for one member fail: refresh on a schedule, one at a time.

Sources: WHOOP integration guide and WHOOP’s webhook documentation.

Polar AccessLink

HMAC signature
Authorize
https://flow.polar.com/oauth2/authorization
Token
https://polarremote.com/v2/oauth2/token (HTTP Basic)
Default scopes
accesslink.read_all
PKCE
not sent: the guide does not document it
Webhooks
HMAC-SHA256 of the payload, keyed by the signature_secret_key returned when you created the webhook; hex, in Polar-Webhook-Signature. Event type in Polar-Webhook-Event.
  • Polar's token endpoint takes HTTP Basic client credentials.
  • The authorization code expires in 10 minutes and must be used once: a second use makes Polar delete every token issued.
  • The response carries x_user_id, the Polar user id later paths use.

Sources: Polar AccessLink integration guide and Polar AccessLink’s webhook documentation.

Strava

Validation handshake; events unsigned
Authorize
https://www.strava.com/oauth/authorize
Token
https://www.strava.com/oauth/token (credentials in the form body)
Default scopes
read,activity:read_all
PKCE
not sent: the guide does not document it
Webhooks
No signature is documented for event POSTs. Subscription validation is a GET with hub.mode, hub.challenge and hub.verify_token; echo hub.challenge as JSON within two seconds.
  • Strava scopes are comma-separated, and activity:read (or activity:read_all) is required for activity webhooks.
  • Access tokens expire six hours after creation; once a new refresh token is returned the old one is invalidated immediately.

Sources: Strava integration guide and Strava’s webhook documentation.

Wahoo

Shared token in the body
Authorize
https://api.wahooligan.com/oauth/authorize
Token
https://api.wahooligan.com/oauth/token (credentials in the form body)
Default scopes
user_read workouts_read offline_data
PKCE
not sent: the guide does not document it
Webhooks
No signature. Wahoo sends the webhook_token you configured inside the JSON body; ignore any request without it.
  • Without user_read Wahoo returns 403, and offline_data is required for webhooks.
  • Access tokens expire after 2 hours; an app may hold 10 unrevoked access tokens per user, so refresh only right before a call.

Sources: Wahoo integration guide and Wahoo’s webhook documentation.

Not included, and why

  • Withings. Its token exchange is a requesttoken call signed with a nonce from its signature service, which a generic skeleton cannot do without guessing; see the Withings guide and the Withings notification router recipe.
  • Terra. Terra connects users through its own widget rather than a per-vendor OAuth app you register, so there is no callback to scaffold; its webhook signature is in the webhook verifier.
  • Google Health API. It uses Google's OAuth, not a vendor-specific endpoint the site documents here; its webhook rule is in the webhook verifier.
  • Garmin. Garmin's public pages state no request format or signing rule for its webhooks.

How the templates are tested

scripts/starter-scaffold.test.mjs generates every stack with each vendor alone and with all five. The Express skeleton passes node --check, is started on a free port, and is driven over HTTP: the authorize redirect, the code exchange against a fake token endpoint, a single-use state, and each vendor’s webhook as the webhook verifier builds it from the vendor’s own example, accepted unchanged and refused when tampered with. The Next.js skeleton passes tsc --noEmit in strict mode against Next.js and Node types, and its webhook module runs the same cases. The FastAPI skeleton passes python -m py_compile, and its standard-library webhook module runs the same cases. The ZIP is read back by the site’s own ZIP reader and by Python’s zipfile.

What it does not do: the stores keep tokens in memory, the start route takes the user id as a query parameter, and the event handler only logs. The README in each download says what to replace, and the cookbook has tested code for each: token encryption at rest, refresh rotation and a replay-safe receiver.

A free tool from AIFitnessAPI. More at free tools for health-app builders.

Next step

Turning this into a choice of API: