Recipe: Route Withings Notifications to the Right Data API Call
Last verified November 5, 2026 · 7 min read
A Withings notification tells you which user and which category changed, and for when; it never carries the data. It arrives as a form-encoded POST with no signature, so the security model is a secret you put in the URL, and the work is choosing which Data API service to call. This recipe does both, returns the call as a job instead of making it, and is tested against the routing the site's webhook verifier encodes from Withings' pages, read October 31, 2026.
What Withings documents#
- The secret. "When you set the callback URL on your subscription, append a query parameter only you and Withings know." Your handler "rejects any value that doesn't match the secret you configured", combined "with the IP allowlist for defense in depth".
- The body. "Withings sends an application/x-www-form-urlencoded POST request to your callback URL", with
userid,appli,startdateandenddate(epoch) ordate, and sometimesdeviceidoraction. - The answer. "Your endpoint returns HTTP 2xx within a few seconds." Failures are retried in five cycles over about five hours; after 20 days of failures the subscription is canceled; "missed notifications are not redelivered".
- The reachability check. A HEAD request before registration, which the handshake recipe answers.
The routing table#
| appli | Category (Withings' example labels) | Call |
|---|---|---|
| 1, 2, 4, 58, 61, 62, 63 | 1 weight and body composition, 2 temperature, 4 blood pressure and heart rate | Measure - Getmeas |
| 16 | activity | Measure v2 - Getactivity, Getintradayactivity or Getworkouts |
| 44 | sleep summary | Sleep v2 - Get or Sleep v2 - Getsummary |
| 54 | ECG | Heart v2 - List |
| 55 | none (notification-only) |
What the recipe does#
- Checks the secret first, in constant time, and returns
403before parsing anything. - Parses the form body and returns
400withoutuseridand an integerappli. - Names the call for every known category and surfaces an unknown one with
call: nulland a reason, still answering200. - Returns a job, not a fetch.
jobForkeys it by user, category and range, so a duplicate notification collapses into one job.
The implementation#
The canonical file is cookbook/withings-notification-router.mjs in this site's repository, and cookbook/withings-notification-router.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies and needs Node 20 or newer; it makes no network calls.
/**
* withings-notification-router.mjs — turn a Withings notification into the
* one Data API call that fetches what changed, after checking the secret on
* the callback URL.
*
* WHAT THIS IMPLEMENTS
* Withings notifications are pointers, not data: a form-encoded POST with
* `userid`, `appli` (the category), and `startdate`/`enddate` (epoch
* seconds) or `date`. `routeWithingsNotification({ url, body }, { secret })`
* 1. checks the secret query parameter Withings suggests you append to
* the callback URL (its example: `?token=<random-secret>`), in
* constant time — there is no signature to check;
* 2. parses the form body;
* 3. returns `{ call, userid, appli, range }`, where `call` names the
* Data API service for that category:
* 1, 2, 4, 58, 61, 62, 63 Measure - Getmeas (startdate-enddate)
* 16 Measure v2 - Getactivity, Getintradayactivity
* or Getworkouts
* 44 Sleep v2 - Get or Sleep v2 - Getsummary
* 54 Heart v2 - List
* 55 nothing to fetch (notification-only)
* and `{ call: null, reason }` for a category this table does not
* know, so an unrouted notification is logged rather than dropped.
*
* Withings says missed notifications are not redelivered and that you
* should backfill "via the Data API using lastupdate" once your endpoint
* is back, so the router also returns the range to fetch rather than
* fetching it: the queue that does the fetch can be replayed; the
* notification cannot.
*
* WHERE THE TABLE COMES FROM
* The category-to-call routing the site's /tools/webhook-verifier module
* encodes from Withings' notification pages (read 2026-10-31 through the
* source-snapshot workflow). The test asserts this table and that one
* agree for every category, so they cannot drift.
*
* No dependencies. Node 20+.
*
* MIT — from aifitnessapi.com/cookbook
*/
import { timingSafeEqual } from "node:crypto";
export const CALLS = Object.freeze({
GETMEAS: "Measure - Getmeas",
ACTIVITY: "Measure v2 - Getactivity, Getintradayactivity or Getworkouts",
SLEEP: "Sleep v2 - Get or Sleep v2 - Getsummary",
HEART: "Heart v2 - List",
NONE: "none (notification-only)",
});
export const APPLI_ROUTES = Object.freeze({
1: CALLS.GETMEAS,
2: CALLS.GETMEAS,
4: CALLS.GETMEAS,
58: CALLS.GETMEAS,
61: CALLS.GETMEAS,
62: CALLS.GETMEAS,
63: CALLS.GETMEAS,
16: CALLS.ACTIVITY,
44: CALLS.SLEEP,
54: CALLS.HEART,
55: CALLS.NONE,
});
function sameSecret(a, b) {
if (typeof a !== "string" || typeof b !== "string" || !b) return false;
const x = Buffer.from(a);
const y = Buffer.from(b);
return x.length === y.length && timingSafeEqual(x, y);
}
function epoch(v) {
if (v == null || v === "") return null;
const n = Number(v);
return Number.isInteger(n) && n >= 0 ? n : null;
}
/**
* @param {{ url: string, body: string|Buffer }} request
* @param {{ secret: string, tokenParam?: string }} config
*/
export function routeWithingsNotification({ url, body }, { secret, tokenParam = "token" }) {
const given = new URL(url, "https://callback.invalid").searchParams.get(tokenParam);
if (!sameSecret(given ?? "", secret)) return { ok: false, status: 403, reason: "callback secret missing or wrong" };
const form = new URLSearchParams(typeof body === "string" ? body : Buffer.from(body ?? "").toString("utf8"));
const userid = form.get("userid");
const appli = Number(form.get("appli"));
if (!userid || !Number.isInteger(appli)) return { ok: false, status: 400, reason: "userid and appli are required" };
const range = { startdate: epoch(form.get("startdate")), enddate: epoch(form.get("enddate")), date: form.get("date") || null };
const action = form.get("action");
const call = APPLI_ROUTES[appli] ?? null;
const base = { ok: true, status: 200, userid, appli, range, deviceid: form.get("deviceid"), action: action || null };
if (!call) return { ...base, call: null, reason: `appli ${appli} is not in this routing table: log it and look it up on Withings' Payload and Categories page` };
return { ...base, call, fetch: call !== CALLS.NONE };
}
/** A queue job for the fetch, keyed so a duplicate notification collapses into one. */
export function jobFor(route) {
if (!route.ok || !route.fetch) return null;
const { userid, appli, range } = route;
const window = range.startdate != null ? `${range.startdate}-${range.enddate ?? ""}` : range.date ?? "all";
return { key: `withings:${userid}:${appli}:${window}`, userid, appli, call: route.call, range };
}
How to adapt it#
Put the secret in the URL when you subscribe, generated per environment and stored with the subscription. Rotating it means re-subscribing with the new URL.
Fetch with the range, then reconcile with lastupdate. Run the job's call over startdate to enddate; after an outage, backfill using lastupdate as Withings advises, since the missed notifications will not come back.
Respect the rate limit in the worker. Withings' standard API allows 120 requests per minute across your application, as the site's Withings guide quotes it; the multi-window pacer takes that as a one-line policy.
The contract the tests hold it to#
Withings' example notification routes to Getmeas with its range; every category in the table names the same call as the site's verifier; the categories the verifier lists are all routed; the verifier and the router accept the same example request; a missing, wrong or empty-configured secret is 403; a body without userid or a numeric appli is 400; an unknown category is 200 with no call and no job; appli 55 needs no fetch; two copies of one notification give one job key and another range gives another; a custom token parameter name works.
What it deliberately does not do#
It does not call the Data API, refresh tokens, or check Withings' IP allowlist, which is published as DNS records that change and belongs in your network layer.
Frequently asked questions
How do you authenticate a Withings notification when it carries no signature?
With a secret you put in the callback URL yourself. Withings' notification guide says to append a query parameter only you and Withings know when you set the callback URL, because Withings POSTs to that exact URL, and to reject any value that does not match; it suggests combining that with its published IP allowlist. So the check is a constant-time comparison of the token parameter against your configured secret before anything else is parsed. The recipe returns 403 for a missing or wrong secret, and refuses to match an empty configured secret, which would otherwise accept every request without a token.
What should a Withings notification handler do when it sees an unfamiliar appli value?
Acknowledge it and log it, never drop it silently. Withings retries a failed notification in cycles over about five hours and cancels the subscription after 20 days of unresolved failures, so returning an error for a category you do not yet handle risks the whole subscription; and a notification you discard is not redelivered. The recipe returns a 200 with call set to null and a reason pointing at Withings' Payload and Categories page, and builds no job for it, so the unknown value shows up in your logs as something to route rather than disappearing.
Why queue the Withings fetch instead of calling the Data API inside the notification handler?
Because the notification is the one thing you cannot get back. Withings says missed notifications are not redelivered and that you should backfill via the Data API using lastupdate, and its guide asks endpoints to return a 2xx within a few seconds. Fetching inline ties the acknowledgment to a slow upstream call, so a Data API hiccup becomes a failed delivery and a retry storm. Returning the call and the date range as a job, keyed by user, category and range so duplicates collapse, lets the handler answer immediately and lets the fetch be retried, replayed or rate-limited on its own.
Keep reading
Elsewhere on the site
Pages that share this one’s concepts and sources, from other sections.
Next steps
Was this page useful?
Corrections that change a page are logged, and credited by name if you ask: contributors.
Cite this page
- Title
- Recipe: Route Withings Notifications to the Right Data API Call
- Publisher
- AIFitnessAPI
- URL
- https://aifitnessapi.com/cookbook/withings-notification-router
- Last verified
AIFitnessAPI. "Recipe: Route Withings Notifications to the Right Data API Call." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/withings-notification-router
@misc{aifitnessapi-cookbook-withings-notification-router,
author = {{AIFitnessAPI}},
title = {Recipe: Route Withings Notifications to the Right Data API Call},
year = {2026},
url = {https://aifitnessapi.com/cookbook/withings-notification-router},
urldate = {2026-11-05},
note = {Last verified 2026-11-05}
}Cite the page, not the site — every page carries its own verification date.
Independent comparison, last reviewed November 5, 2026. Pricing, rate limits, and feature availability change often — confirm current details in each provider’s official documentation before you commit. Product and company names are trademarks of their respective owners; AIFitnessAPI is not affiliated with, endorsed by, or sponsored by any product listed here.
← All cookbook · by AIFitnessAPI