Build Log · Data Pipeline

Automating a nutrition coach's Google Sheet

A serverless pipeline that pulls weight from Withings, macros from Cronometer's unpublished GWT-RPC endpoints, and steps + sleep from Zepp's own cloud API, then writes all of it into a coach's spreadsheet every morning — built with a "prove it locally first" discipline before a single AWS resource existed.

Stack: EventBridge → Lambda → Withings / Cronometer / Zepp / Sheets / SES Monthly cost: ~$0.00 (free tier, 1 run/day) Support plan: Basic Updated: 2026-08-02
Hop 01 · Foundation

Local first, AWS second

Every credential and every coordinate got proven on a laptop before a single AWS resource existed: a 40-test pure-logic suite with no network calls, then a DRY_RUN=1 mode that logs every intended spreadsheet write without touching the workbook. Debugging a cold Lambda start is worse than debugging a local shell, and every real failure mode here turned out to be a credential or a coordinate — both easier to see on a laptop.

Hop 02 · Google Sheets

Desktop OAuth client, minted once

A one-time bootstrap script spins up a throwaway localhost listener, opens the Google consent screen, and exchanges the resulting code for a refresh token — the only artifact the Lambda ever needs afterward.

Gotcha First attempt came back Error 403: access_denied — "has not completed the Google verification process." The consent screen was set to External but the account authorizing it hadn't been added under Test users yet. Adding it there took effect immediately, no propagation wait.
Hop 03 · Withings

A refresh token that never stops rotating

Withings issues a brand-new refresh token on every use and invalidates the one before it — the opposite of a "set once" secret. Lambda environment variables are immutable at runtime, so the token lives in SSM Parameter Store instead, and the client writes the rotated value back before it ever risks losing the only valid copy.

Gotcha Withings' own account portal threw a generic "A website error has occurred" mid-2FA — nothing to do with our code, just a flaky third-party auth page. A retry in a clean session cleared it.
Hop 04 · Cronometer

Driving an API that was never meant to be public

Personal Cronometer accounts get no API — that tier is enterprise-only. The pipeline instead drives the web app's own internal GWT-RPC endpoints, captured from DevTools: a login flow, a permutation/header pair baked into each Cronometer deploy, and an account ID that lets the client skip the one RPC never fully verified against a live response.

Gotcha The export response arrives labeled Content-Type: application/json — but the body is GWT wire format (//OK[...]), not JSON. The server mislabels itself; the client has to know to ignore the header and parse GWT anyway.

The account has roughly ten exports a day before Cronometer's quota kicks in, so retries are capped rather than looped — and logins are rate-limited hard enough that the authenticated session gets cached in SSM between runs instead of re-logging in every time.

Hop 05 · Design

Independent, best-effort stages

Weight and nutrition are pulled and written as two isolated stages, each wrapped so one failing never sinks the other — a Cronometer outage shouldn't cost a valid weigh-in, and a missed weigh-in shouldn't block a nutrition update. The handler returns a per-stage status (ok / skipped / error) rather than throwing, and "skipped" is deliberately never conflated with "error": no weigh-in logged, no food logged, and a date outside the coaching window are all normal conditions, not failures.

Gotcha The workbook has no dates anywhere — weeks are ordinal labels, and the coach's actual tracking week runs Sunday→Saturday. The anchor date has to land on a Sunday exactly, or a single calendar week silently gets split across two spreadsheet rows, corrupting the built-in =average(B:H) for both. A day of the week isn't a cosmetic detail here — it's load-bearing, the same way a missing default root object was on the last build.
Hop 06 · Packaging

Building for Lambda's platform, not the laptop's

Dependencies get resolved with pip install --platform manylinux2014_x86_64 --only-binary=:all: rather than a bare pip install, so the build host's own OS and CPU never leak into the artifact — packaging from Windows or Apple Silicon would otherwise produce a zip Lambda can't import. boto3 is deliberately left out; the Lambda runtime already provides it, and bundling it would roughly double the package for no benefit. Final artifact: about 2.6 MB, comfortably under the console's 50 MB upload ceiling.

Hop 07 · Incident

A CLI that predates the API it's being asked to call

Wiring the daily trigger through EventBridge's newer Scheduler service failed immediately, and it looked at first like the same IAM-propagation delay seen earlier when a fresh role needed a few seconds before Lambda would trust it. It wasn't.

$ diagnose --target nutrition-sync-daily
1Scheduler's IAM role trusts scheduler.amazonaws.comOK
2AWS credentials configured and authenticatedOK
3Target Lambda ARN exists and is invokableOK
4aws scheduler create-schedule recognized as a commandFAIL
Root cause: the installed AWS CLI was v1.22.34 — a build from before EventBridge Scheduler existed as a service. No amount of retrying fixes a client that has never heard of the API being called; the earlier "wait and retry" instinct from an IAM propagation delay didn't apply here at all. Installing AWS CLI v2 (no root available, so straight into the user's home directory) resolved it on the very next attempt.
Hop 08 · Schedule & alarms

Closing the loop

A daily EventBridge schedule invokes the function at 06:00 UTC — after the overnight weigh-in, well ahead of the coach's evening deadline. A CloudWatch alarm watches the Errors metric with missing-data treated as good, since the pipeline is silent by design on ordinary skips; an SNS topic forwards a breach straight to email. Log retention got set to 30 days explicitly — the console default is Never expire, which quietly accrues cost for logs nobody will read a year from now.

Scheduled · arn:aws:scheduler:us-east-1:735291151407:schedule/default/nutrition-sync-daily
Hop 09 · Steps & sleep

Locked out on the first login

Weight and macros were covered; steps and sleep from a Zepp/Amazfit band were not. The same reverse-engineered-client pattern that worked for Cronometer looked like the obvious move: Zepp's private Huami Cloud API has a login endpoint, a token exchange, and a data call — no different in shape from what was already running.

Gotcha Two login attempts got the account locked outright — every following request drew a flat 429 {"code":12,"message":"too many requests"}. A differential test (an unrelated account logging in cleanly against the same endpoint moments later) confirmed the block was tied to this account specifically, not a generic rate limit — and it survived a completely normal, successful login on the real Zepp app itself. No decay timer, no trust reset. Paused rather than risk a forced password reset by continuing to poke it.
Hop 10 · The real fix

A quiet API overhaul, and a header that got around it

A look at the community projects built around this API (Gadgetbridge's ecosystem, a couple of Python CLIs, one repo that turned out to be Huami's own official partner-API docs, misleadingly named like a community one) showed the provider had overhauled their login in the meantime: the mobile app's flow now requires an encrypted payload. That's plausibly part of why the earlier plaintext attempt got flagged in the first place.

A browser-flavored version of the same original login endpoint — matching the Zepp web app's headers instead of the mobile app's — sidestepped the encryption requirement entirely and logged in clean, with no lockout. The data endpoint turned out to be friendlier than expected too: its summary field is plain base64-encoded JSON, not the opaque binary blob some docs implied. Decoded steps matched the app's own displayed count exactly on the first real request.

Gotcha Personal accounts get no refresh token here, only a login that's valid roughly 30 days. Rather than treat that as a recurring manual chore, the client just re-runs the full login automatically whenever a cached session gets rejected — the same pattern already used for Cronometer's rate-limited logins, just with a much longer cache lifetime.

Steps and sleep are now a third pulled stage sitting right alongside Withings and Cronometer in the one function.

Hop 11 · Going fully live

Nine a.m., one Lambda, one email

The schedule moved from a fixed 06:00 UTC to cron(0 9 * * ? *) in the named timezone America/New_York rather than a raw UTC offset — a fixed offset would drift an hour off "9am local" every time the clocks change. 9am gives the morning weigh-in time to actually happen before the run, so it lands in the same pass as the previous night's sleep and the prior day's completed food and steps.

Gotcha DRY_RUN had been left on from initial setup, and it silently broke Withings' auth a day later. Withings invalidates a refresh token the instant it's used and issues a replacement — but the client only persists that replacement if not dry_run, so the safety flag meant to prevent side effects also quietly discarded the one live credential the next run needed. Two features colliding, neither one wrong on its own.

Last addition: a best-effort summary email after every run — weight, each day's macros, each day's steps and sleep — sent through SES. It's wrapped in its own try/except in the handler; a failed email should never be allowed to mask or fail an otherwise-successful write.

Live · all four sources (weight, nutrition, steps, sleep) pulled and written in one scheduled run
Hop 12 · Silent expiry

A refresh token with an undocumented shelf life

A week into unattended runs, the whole pipeline went dark — not one stage, all of them. SheetsWriter is constructed before the Withings, Cronometer, and Zepp stages ever run, so a dead Google credential took the entire day's run down with it: RefreshError: invalid_grant: Token has been expired or revoked. Nothing had been revoked. Nobody had touched permissions.

$ diagnose --target google-oauth
1Client ID / secret unchanged, still validOK
2Sheets API enabled on the projectOK
3Workbook still shared with the authorizing accountOK
4Refresh token still exchanges for an access tokenFAIL
Root cause: the OAuth consent screen was still sitting in Testing publishing status, months after the last verification. Google caps refresh tokens minted under Testing at 7 days flat, no matter how often they're used — a quieter, deadline-driven cousin of Withings' rotate-on-use expiry from Hop 08, but with zero signal in the token response itself that a clock was even running.

Fixed by publishing the app (a one-time click, no verification review required at this account's usage level — just a one-screen "unverified app" warning on the next consent) and re-minting the token afterward. The first re-mint, done before publishing, kept the 7-day expiry anyway — a token's lifetime is set at issue time, not by whatever the app's status happens to be later. Order mattered: publish first, mint second.

Fixed · consent screen published, refresh token re-minted post-publish, verified against a live run