Recipe: Answer Webhook Subscription Handshakes Correctly
Last verified November 5, 2026 · 7 min read
Before a fitness API sends a single event it asks your endpoint to prove something, and each vendor asks differently. Get the answer wrong and the subscription simply never exists: no error in your logs, no events, and a support thread that starts with "webhooks are not firing". This recipe is one function per vendor's documented handshake, written so that anything that is not a handshake falls through to your signature check and ingestion untouched.
The five handshakes#
| Vendor | Request | Expected answer |
|---|---|---|
| Oura | GET ?verification_token=…&challenge=… | 200 with {"challenge": "…"} when the token matches; Oura's sample answers a mismatch with 401 |
| Strava | GET ?hub.mode=subscribe&hub.challenge=…&hub.verify_token=… | 200, application/json, {"hub.challenge": "…"}, within two seconds |
| Polar | POST with {"event": "PING", …} | 200 OK, or the webhook is not created or modified |
| Withings | HEAD to the callback URL | 2xx, before Withings registers the URL |
| Google Health API | two POSTs with {"type": "verification"} | 200 (or 201) with your Authorization header; 401 (or 403) without |
Each line is quoted on the webhook verifier from the vendor's own page, read October 31, 2026. Strava adds that "your callback address must respond within two seconds"; the handler does no I/O, so it answers inside every vendor's limit by construction.
What the recipe does#
- Takes a framework-neutral request (
method,url,headers,body) and returns either the exact response to send or{ handled: false }. - Compares every secret in constant time: Oura's
verification_token, Strava'sverify_token, the Google Health API's Authorization value. - Echoes nothing for a wrong token. Oura's sample answers
401; for Strava, whose page documents only the success case, the recipe answers403and says so. - Never throws on a strange body. A POST that is not JSON is passed through, where the signature check will refuse it.
The implementation#
The canonical file is cookbook/webhook-handshakes.mjs in this site's repository, and cookbook/webhook-handshakes.test.mjs runs against it on every CI run. The listing below is a verbatim copy of that file. It has no runtime dependencies, needs Node 20 or newer, and returns plain objects you translate into your framework's response.
/**
* webhook-handshakes.mjs — answer the subscription-validation requests that
* Oura, Strava, Polar, Withings and the Google Health API send before they
* deliver a single event.
*
* WHAT THIS IMPLEMENTS
* `answerHandshake(vendor, request, config)` takes a framework-neutral
* request `{ method, url, headers, body }` and returns either
* `{ handled: true, status, headers, body }` — the exact response the vendor
* expects — or `{ handled: false }`, meaning "this is a normal event, carry
* on to signature checks and ingestion". A subscription that never gets the
* right answer is never created, and nothing tells you why.
*
* oura GET ?verification_token=…&challenge=… → 200 {"challenge": …}
* when the token matches; Oura's sample answers a mismatch 401
* strava GET ?hub.mode=subscribe&hub.challenge=…&hub.verify_token=…
* → 200 application/json {"hub.challenge": …}, within 2 s
* polar POST whose body is {"event": "PING", …} → 200; without it the
* webhook is not created or modified
* withings HEAD to the callback URL → 2xx, before Withings registers it
* google POST {"type": "verification"} twice: with your configured
* Authorization header → 200 (or 201); without credentials →
* 401 (or 403)
*
* Every secret comparison is constant-time. The handler does no I/O, so it
* answers inside every vendor's time limit by construction.
*
* WHERE THE RULES COME FROM
* The vendor pages quoted on https://aifitnessapi.com/tools/webhook-verifier
* (read 2026-10-31), whose evidence file holds the sentence behind each
* line above.
*
* No dependencies. Node 20+ (or any runtime with Buffer and URL).
*
* MIT — from aifitnessapi.com/cookbook
*/
import { timingSafeEqual } from "node:crypto";
const JSON_HEADERS = Object.freeze({ "content-type": "application/json" });
function same(a, b) {
if (typeof a !== "string" || typeof b !== "string") return false;
const x = Buffer.from(a, "utf8");
const y = Buffer.from(b, "utf8");
return x.length === y.length && timingSafeEqual(x, y);
}
function header(headers, name) {
if (!headers) return null;
if (typeof headers.get === "function") return headers.get(name);
for (const [k, v] of Object.entries(headers)) if (k.toLowerCase() === name) return Array.isArray(v) ? v[0] : String(v);
return null;
}
function query(url) {
return new URL(url, "http://callback.invalid").searchParams;
}
function parseJson(body) {
if (body == null || body === "") return null;
try {
return JSON.parse(typeof body === "string" ? body : Buffer.from(body).toString("utf8"));
} catch {
return null;
}
}
const reply = (status, body = "", headers = {}) => ({ handled: true, status, headers, body });
const PASS = Object.freeze({ handled: false });
/** Oura: GET with verification_token and challenge. */
export function oura({ method, url }, { verificationToken }) {
if (method !== "GET") return PASS;
const q = query(url);
if (!q.has("verification_token") || !q.has("challenge")) return PASS;
if (!same(q.get("verification_token"), verificationToken)) return reply(401);
return reply(200, JSON.stringify({ challenge: q.get("challenge") }), JSON_HEADERS);
}
/** Strava: GET with hub.mode, hub.challenge and hub.verify_token. */
export function strava({ method, url }, { verifyToken }) {
if (method !== "GET") return PASS;
const q = query(url);
if (q.get("hub.mode") !== "subscribe" || !q.has("hub.challenge")) return PASS;
if (!same(q.get("hub.verify_token"), verifyToken)) return reply(403);
return reply(200, JSON.stringify({ "hub.challenge": q.get("hub.challenge") }), JSON_HEADERS);
}
/** Polar: a PING event must get 200 OK or the webhook is not created. */
export function polar({ method, body }) {
if (method !== "POST") return PASS;
const json = parseJson(body);
return json && json.event === "PING" ? reply(200) : PASS;
}
/** Withings: a HEAD to the callback URL must answer 2xx. */
export function withings({ method }) {
return method === "HEAD" ? reply(200) : PASS;
}
/**
* Google Health API: two verification POSTs, one with your configured
* Authorization value and one without credentials. `authorization` is the
* exact value you configured (for example "Bearer …").
*/
export function google({ method, headers, body }, { authorization }) {
if (method !== "POST") return PASS;
const json = parseJson(body);
if (!json || Array.isArray(json) || json.type !== "verification") return PASS;
const sent = header(headers, "authorization");
return same(sent, authorization) ? reply(200) : reply(401);
}
export const HANDSHAKES = Object.freeze({ oura, strava, polar, withings, google });
/**
* @param {"oura"|"strava"|"polar"|"withings"|"google"} vendor
* @param {{ method: string, url: string, headers?: object, body?: string|Buffer }} request
* @param {object} config verificationToken (oura), verifyToken (strava), authorization (google)
*/
export function answerHandshake(vendor, request, config = {}) {
const fn = HANDSHAKES[vendor];
if (!fn) throw new Error(`no handshake rule for ${vendor}`);
const req = { ...request, method: String(request.method || "GET").toUpperCase() };
return fn(req, config);
}
How to adapt it#
Call it first. In the route handler, run answerHandshake before signature verification: Strava's and Oura's validation GETs carry no signature, and the Google Health API's second verification request deliberately carries no credentials.
Store the tokens with the subscription. Strava's hub.verify_token is "whatever verify_token you passed" when you created the subscription, so keep it next to the subscription id and rotate both together.
Keep Withings' HEAD route open. Some frameworks answer HEAD automatically for GET routes only; a POST-only route can return 405 to Withings' reachability check.
The contract the tests hold it to#
Oura: a matching token echoes the challenge as JSON, a wrong one gets 401 with an empty body, and the reply equals the one the site's verifier builds. Strava: the challenge is echoed under its own key, the reply equals the verifier's for Strava's documented example, a wrong token is refused and an event POST is passed through. Polar: a PING gets 200 with no body and an EXERCISE event is passed through. Withings: HEAD answers 200, the form-encoded POST is passed through. Google Health API: the credentialed verification gets 200, the bare and the wrong ones 401, and single or batched data notifications are passed through. Garbage bodies are passed through rather than thrown on.
What it deliberately does not do#
It does not create subscriptions, verify event signatures or acknowledge events; vendor webhook signatures and the webhook receiver handle the deliveries that follow. It does not cover WHOOP or Terra, whose quoted pages describe no validation request, or Wahoo, whose check is the token in each event body.
Frequently asked questions
Why does the Google Health API send two verification requests to a webhook endpoint?
To prove that your endpoint checks credentials, not just that it exists. Google's webhook page says the first verification request carries your configured Authorization header and must get a 200 OK or 201 Created, and the second is sent without credentials and must get a 401 Unauthorized or 403 Forbidden. An endpoint that answers 200 to everything fails the second request, which is the point: it would also accept forged notifications. The recipe compares the Authorization header with your configured value in constant time and answers 200 or 401 accordingly; batched data notifications arrive as a JSON array and are passed through to your normal handler.
What should a Strava callback return when hub.verify_token does not match?
Strava's webhook page specifies only the success case: status 200 with the hub.challenge echoed as application/json within two seconds. It does not say what a mismatch should return. The recipe answers 403 with an empty body, which refuses the subscription without echoing anything, and labels that as its own choice. Whatever you pick, do not echo the challenge for a wrong token: echoing is what tells Strava the callback belongs to the subscriber, so an endpoint that echoes unconditionally can be subscribed by anyone who knows its URL.
Does a Polar AccessLink PING need to be signed-checked before answering?
Answer it with 200 OK in any case, because Polar says the receiving service must respond to the ping message with 200 OK or the webhook is not created or modified. Polar signs every payload with the key it returns when the webhook is created, so you can verify the PING's Polar-Webhook-Signature too, but a PING carries no user data and failing it leaves you without a webhook. The recipe recognizes a POST whose JSON body has event PING and answers 200 with no body; every other Polar event is passed through, to the signature check in the vendor-signatures recipe.
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: Answer Webhook Subscription Handshakes Correctly
- Publisher
- AIFitnessAPI
- URL
- https://aifitnessapi.com/cookbook/webhook-handshakes
- Last verified
AIFitnessAPI. "Recipe: Answer Webhook Subscription Handshakes Correctly." Last verified 2026-11-05. https://aifitnessapi.com/cookbook/webhook-handshakes
@misc{aifitnessapi-cookbook-webhook-handshakes,
author = {{AIFitnessAPI}},
title = {Recipe: Answer Webhook Subscription Handshakes Correctly},
year = {2026},
url = {https://aifitnessapi.com/cookbook/webhook-handshakes},
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