---
title: "Recipe: Route Withings Notifications to the Right Data API Call"
canonical: "https://aifitnessapi.com/cookbook/withings-notification-router"
cluster: "Cookbook"
primary_query: "withings notification appli callback handler code"
last_reviewed: "2026-11-05"
description: "Tested Node code that checks a Withings notification's callback secret, parses its form body and names the Data API call each appli category needs."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Route Withings Notifications to the Right Data API Call\", AIFitnessAPI, https://aifitnessapi.com/cookbook/withings-notification-router"
---

# Recipe: Route Withings Notifications to the Right Data API Call

> A dependency-free Node router for Withings notifications. It checks the secret query parameter Withings suggests you append to your callback URL, in constant time, since Withings sends no signature; parses the form-encoded body for userid, appli and the startdate and enddate range; and names the Data API call each category needs: Measure - Getmeas for weight, temperature and blood-pressure categories, Measure v2 activity calls for 16, Sleep v2 for 44, Heart v2 - List for 54, and nothing for 55. An unknown category is acknowledged and surfaced rather than dropped, and duplicate notifications collapse into one job key. Its table is tested against the site's webhook verifier.

- Canonical: https://aifitnessapi.com/cookbook/withings-notification-router
- Last reviewed: 2026-11-05
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Route Withings Notifications to the Right Data API Call", AIFitnessAPI, https://aifitnessapi.com/cookbook/withings-notification-router

---

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](/tools/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`, `startdate` and `enddate` (epoch) or `date`, and sometimes `deviceid` or `action`.
- **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](/cookbook/webhook-handshakes) 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 `403` before parsing anything.
- **Parses the form body** and returns `400` without `userid` and an integer `appli`.
- **Names the call** for every known category and surfaces an unknown one with `call: null` and a reason, still answering `200`.
- **Returns a job, not a fetch.** `jobFor` keys 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.

```js
/**
 * 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](/integrate/withings-api) quotes it; [the multi-window pacer](/cookbook/multi-window-rate-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.

## FAQ

### 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.

[Permalink](https://aifitnessapi.com/cookbook/withings-notification-router#faq-1)

### 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.

[Permalink](https://aifitnessapi.com/cookbook/withings-notification-router#faq-2)

### 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.

[Permalink](https://aifitnessapi.com/cookbook/withings-notification-router#faq-3)
