A single-tenant, self-hosted Fitbit vitals wrapper, Prometheus exporter and dashboard. One
wrangler deploy gives you a dashboard, a /metrics scrape endpoint, and a JSON API — all
running inside the Cloudflare free tier (Workers + D1 + Durable Objects + R2).
POST /webhook/fitbit verifies HMAC-SHA1(body, FITBIT_CLIENT_SECRET) before accepting any payload, preserving the "no auth-protected
endpoints" invariant for the rest of the API.refresh_token./metrics with freshness, rate-limit, and token-expiry gauges for true
observability (see docs/metrics.md).@cloudflare/vitest-pool-workers smoke tests running in a real miniflare runtime.main.Fitbit Web API ─── OAuth 2.0 (PKCE) ───┐
│ POST /webhook/fitbit (signed) │
▼ ▼
┌─────────── Cloudflare Worker ───────────┐ HTTPS
│ Webhook + Cron (×4) ─► TokenStore (DO) │◄──────── Prometheus / Grafana (/metrics)
│ │ │ │ │
│ │ ▼ ▼ │
│ └─► D1 (vitals / daily / tokens) │◄──────── Svelte GUI (/, /#history)
│ │ │
│ └─► R2 archive │◄──────── Any HTTP client (/api/vitals/*)
└──────────────────────────────────────────┘
See docs/DESIGN.md for the full design,
docs/architecture.md for the infra breakdown,
docs/fitbit-api.md for the Fitbit API cheat-sheet,
and docs/metrics.md for the Prometheus metrics catalog.
[email protected])Personal (required — gives automatic Intraday API access)http://localhost:48125/callback/webhook/fitbit route.pnpm install
pnpm --filter @fitbit-vital-monitor/worker d1:create
pnpm --filter @fitbit-vital-monitor/worker r2:create
Copy the printed D1 database_id into apps/worker/wrangler.toml under [[d1_databases]].
FITBIT_CLIENT_ID=xxxxxx FITBIT_CLIENT_SECRET=yyyyyy pnpm bootstrap
The CLI spins up a loopback HTTP server on port 48125, opens the Fitbit consent screen in your
browser, completes the Authorization Code + PKCE flow, and prints the exact
wrangler secret put commands you need next.
cd apps/worker
echo "<refresh_token from bootstrap>" | pnpm exec wrangler secret put FITBIT_REFRESH_TOKEN_SEED
echo "<client_id>" | pnpm exec wrangler secret put FITBIT_CLIENT_ID
pnpm exec wrangler secret put FITBIT_CLIENT_SECRET # paste at the prompt
echo "<verification code from Fitbit>" | pnpm exec wrangler secret put FITBIT_SUBSCRIBER_VERIFY
cd ../..
pnpm --filter @fitbit-vital-monitor/worker d1:migrate:remote
pnpm deploy:worker # builds the GUI then runs `wrangler deploy`
pnpm deployis a pnpm built-in — usedeploy:workerorpnpm run deploy:worker.
Now that the Worker is live, point Fitbit at it and register the per-collection subscriptions.
5a. Add the endpoint URL in the Fitbit Developer portal. Open your Subscriber from the
Prerequisites step and set its endpoint URL to
https://<your-worker>.workers.dev/webhook/fitbit (Type: JSON). Fitbit immediately sends a
verification GET /webhook/fitbit?verify=<code>; the Worker matches it against
FITBIT_SUBSCRIBER_VERIFY and returns 204. The endpoint should turn green / Verified. If it
stays red, the secret value doesn't match the portal's Verification Code — re-run
pnpm exec wrangler secret put FITBIT_SUBSCRIBER_VERIFY and re-trigger verification.
5b. Register the sleep / activities / body subscriptions. Pass an access token
(printed by pnpm bootstrap) and the Subscriber ID from the portal:
FITBIT_ACCESS_TOKEN=<access_token from bootstrap> \
FITBIT_SUBSCRIBER_ID=<subscriber id from dev.fitbit.com> \
pnpm subscribe
This is idempotent — re-running it against the same subscription IDs is a no-op on Fitbit's side.
The first webhook delivery (within minutes of your next watch sync) populates the dashboard and
exposes /metrics. Heart-rate intraday and device info still arrive via cron on their own
cadence.
After deployment the Worker URL (https://<name>.workers.dev) serves:
| Path | What |
|---|---|
/ |
Svelte dashboard (latest cards + devices) |
/#history |
Metric history with daily / intraday toggle |
/api/vitals/latest |
Latest intraday + daily values + devices (JSON) |
/api/vitals/daily?metric=…&from=YYYY-MM-DD&to=YYYY-MM-DD |
Daily series |
/api/vitals/intraday?metric=…&date=YYYY-MM-DD |
Intraday samples for a given day (7-day retention) |
/metrics |
Prometheus exposition (see docs/metrics.md) |
/webhook/fitbit |
Fitbit Subscription notification receiver (signed) + verification challenge |
/healthz |
Liveness probe |
Point Prometheus at /metrics with a 30–60 s scrape interval — scrape frequency and Fitbit
pull frequency are independent.
If you deployed a pre-webhook version of this Worker, migrate without downtime. D1 / R2 /
auth_tokens are untouched. The order matters: the Worker must be live with /webhook/fitbit
before you register the endpoint URL on Fitbit's side, otherwise Fitbit's verification GET
will hit the old worker (no such route → 404 → endpoint marked unverified).
1. Create a Subscriber on the Fitbit side — without an endpoint URL
dev.fitbit.com/apps → your app → Subscriber Endpoints → create a new Subscriber and leave the endpoint URL field empty for now. Copy the issued Verification Code and Subscriber ID (numeric). Filling the URL at this stage would trigger Fitbit's verification immediately against your still-old Worker, which would fail.
2. Add the new secret and deploy the new Worker
git pull
cd apps/worker
echo "<verification code>" | pnpm exec wrangler secret put FITBIT_SUBSCRIBER_VERIFY
cd ../..
pnpm deploy:worker
wrangler.toml cron schedules are unchanged at the time-slot level (*/15, 0 *, 0 23,
0 */4); the deploy just swaps each handler for its slimmed-down replacement and exposes
/webhook/fitbit. The secret takes effect immediately for new requests — no second deploy
needed.
3. Add the endpoint URL → Fitbit verifies → register Subscriptions
Back in the Fitbit Developer portal, edit the Subscriber from step 1 and set the endpoint URL
to https://<your-worker>.workers.dev/webhook/fitbit. Fitbit sends a verification GET against
the now-live Worker — the endpoint should turn green / Verified. If it stays red, the
secret value doesn't match the portal's Verification Code; re-set it with pnpm exec wrangler secret put FITBIT_SUBSCRIBER_VERIFY and re-trigger verification from the portal.
Then fetch the current access token from D1 and register the per-collection subscriptions (idempotent):
ACCESS_TOKEN=$(pnpm exec wrangler d1 execute fitbit_vital_monitor --remote \
--json --command="SELECT access_token FROM auth_tokens WHERE id=1" \
| jq -r '.[0].results[0].access_token')
FITBIT_ACCESS_TOKEN="$ACCESS_TOKEN" \
FITBIT_SUBSCRIBER_ID=<subscriber id from Fitbit portal> \
pnpm subscribe
unset ACCESS_TOKEN
Tail the Worker (pnpm --filter @fitbit-vital-monitor/worker exec wrangler tail) and sync your
watch — you should see POST /webhook/fitbit 204 lines as activities/sleep/body
notifications arrive.
Rollback:
git checkoutthe previous commit and re-deploy. The Subscription registrations remain on Fitbit's side; notifications hit the old worker and 404 harmlessly. To purge them,DELETE /1/user/-/{collection}/apiSubscriptions/{id}.jsonagainst Fitbit (small edit toscripts/subscribe.ts).
When the high-frequency cron misses a window (CPU limit, deploy outage, transient
failure) and the gap is still inside Fitbit's 7-day intraday retention, replay it
through INSERT OR IGNORE:
# Dry-run: prints SQL to stdout, applies nothing.
pnpm backfill:intraday -- --date 2026-04-19 --from 20:00
# Apply for real:
pnpm backfill:intraday -- --date 2026-04-19 --from 20:00 --apply
The script reads the current access_token from D1, calls Fitbit's 1-minute
intraday endpoint, filters to [--from, --to] (local time in --tz, default
Asia/Tokyo / full day) and writes via wrangler d1 execute --remote. Existing
rows are no-ops thanks to the UNIQUE(metric_type, timestamp) constraint.
If the auth_tokens row is lost, Fitbit revokes consent, or the Worker is idle long enough for
the refresh_token to expire: re-run pnpm bootstrap, overwrite FITBIT_REFRESH_TOKEN_SEED,
then delete the auth_tokens row so the Worker falls back to the new seed on the next cron
tick.
pnpm exec wrangler d1 execute fitbit_vital_monitor --remote --command="DELETE FROM auth_tokens"
If consent was revoked, the Subscription registrations on Fitbit's side are dropped too — re-run
pnpm subscribe after the new bootstrap completes.
Two terminals — Vite proxies /api/* and /metrics to wrangler dev:
# Terminal 1: Worker on :8787
cp apps/worker/.dev.vars.example apps/worker/.dev.vars
# fill in FITBIT_CLIENT_ID / FITBIT_CLIENT_SECRET / FITBIT_REFRESH_TOKEN_SEED / FITBIT_SUBSCRIBER_VERIFY
pnpm --filter @fitbit-vital-monitor/worker d1:migrate:local
pnpm dev:worker
# Terminal 2: Svelte GUI on :5173 with HMR
pnpm dev:gui
Crons don't fire automatically in wrangler dev; trigger them manually:
curl "http://localhost:8787/__scheduled?cron=*/15+*+*+*+*" # High frequency: heart rate intraday
curl "http://localhost:8787/__scheduled?cron=0+*+*+*+*" # Hourly: device info
curl "http://localhost:8787/__scheduled?cron=0+23+*+*+*" # Daily fallback (08:00 JST)
curl "http://localhost:8787/__scheduled?cron=0+*/4+*+*+*" # Body cron: R2 archive sweep
Webhook notifications can't be exercised against localhost — Fitbit pushes to a public URL.
Two options for testing:
wrangler dev via cloudflared tunnel and temporarily point your Subscriber Endpoint at the tunnel URL.apps/worker/src/fitbit/webhook-signature.ts) and curl it at http://localhost:8787/webhook/fitbit with the X-Fitbit-Signature header.pnpm lint # biome
pnpm typecheck # tsc on worker + scripts + svelte-check on gui
pnpm test # 126 unit tests + 4 pool-workers smoke tests
The worker package has two test suites:
test:unit — Node tests backed by an in-memory fake D1 (better-sqlite3) and fake R2.test:workers — real Workers runtime via @cloudflare/vitest-pool-workers (miniflare)..github/workflows/ci-cd.yml runs every push and PR, and
additionally applies D1 migrations + wrangler deploys on pushes to main.
Required GitHub repository secrets:
| Secret | How to obtain |
|---|---|
CLOUDFLARE_API_TOKEN |
dash.cloudflare.com/profile/api-tokens → Create Token → use the "Edit Cloudflare Workers" template, then add D1 : Edit and R2 Storage : Edit on the same account. (The newer R2 SQL / R2 Data Catalog permissions are not required.) |
CLOUDFLARE_ACCOUNT_ID |
Shown in the Cloudflare dashboard sidebar or via wrangler whoami. |
Fitbit secrets (FITBIT_CLIENT_ID / FITBIT_CLIENT_SECRET / FITBIT_REFRESH_TOKEN_SEED /
FITBIT_SUBSCRIBER_VERIFY) are not managed by CI — they live as Worker Secrets and are
untouched by wrangler deploy.
Fitbit returns 403 PERMISSION_DENIED on intraday endpoints. Your app is not registered
as Personal. Open dev.fitbit.com/apps → your app → set
OAuth 2.0 Application Type to Personal and save. Existing tokens keep working after the
change.
No migrations to apply! on first d1:migrate:remote. The migration ran successfully —
wrangler prints this when the d1_migrations metadata table already records the file. Verify
with pnpm exec wrangler d1 execute fitbit_vital_monitor --remote --command="SELECT name FROM sqlite_master WHERE type='table'".
Dashboard shows empty intraday but daily is populated. The Fitbit cloud hasn't received
any new 1-minute samples since the last watch sync — this is normal and resolves at the next
watch↔phone↔cloud sync (~15 min cadence).
Nothing happens after deploy. Cron triggers fire on their own schedule — */15 at the next
:00 / :15 / :30 / :45. Check pnpm --filter @fitbit-vital-monitor/worker exec wrangler tail
for live logs.
Webhook isn't pushing. Confirm in the Fitbit Developer portal that the Subscriber Endpoint
URL points at https://<your-worker>.workers.dev/webhook/fitbit and shows a green
Verified status. If it's red, the verification challenge failed — check that
FITBIT_SUBSCRIBER_VERIFY matches the Verification Code shown in the portal exactly. After
fixing, re-trigger verification from the portal. Then run pnpm subscribe to (re)register the
collection-level subscriptions.
Webhook returns 401. The signature check rejected the request — usually FITBIT_CLIENT_SECRET
in the Worker doesn't match the OAuth app credentials Fitbit signs with. Re-set the secret with
pnpm exec wrangler secret put FITBIT_CLIENT_SECRET.
MIT. See LICENSE.