---
title: "Recipe: Answer Webhook Subscription Handshakes Correctly"
canonical: "https://aifitnessapi.com/cookbook/webhook-handshakes"
cluster: "Cookbook"
primary_query: "webhook subscription verification challenge response code"
last_reviewed: "2026-11-05"
description: "Tested Node code that answers the validation requests Oura, Strava, Polar, Withings and the Google Health API send before a subscription goes live."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Answer Webhook Subscription Handshakes Correctly\", AIFitnessAPI, https://aifitnessapi.com/cookbook/webhook-handshakes"
---

# Recipe: Answer Webhook Subscription Handshakes Correctly

> A dependency-free Node function that answers the subscription-validation requests five fitness APIs send before they deliver any events, and passes every ordinary event through untouched. Oura sends a GET with verification_token and challenge and expects {"challenge": …} back; Strava sends a GET with hub.mode, hub.challenge and hub.verify_token and expects {"hub.challenge": …} as application/json within two seconds; Polar's PING must get a 200; Withings sends a HEAD that must get a 2xx; and the Google Health API sends two verification POSTs, one with your Authorization value, to be answered 200, and one without, to be answered 401. Its tests compare Oura's and Strava's replies with those the site's webhook verifier builds.

- Canonical: https://aifitnessapi.com/cookbook/webhook-handshakes
- Last reviewed: 2026-11-05
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Answer Webhook Subscription Handshakes Correctly", AIFitnessAPI, https://aifitnessapi.com/cookbook/webhook-handshakes

---

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 `POST`s with `{"type": "verification"}` | `200` (or `201`) with your Authorization header; `401` (or `403`) without |

Each line is quoted on [the webhook verifier](/tools/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's `verify_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 answers `403` and 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.

```js
/**
 * 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](/cookbook/vendor-webhook-signatures) and [the webhook receiver](/cookbook/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.

## FAQ

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

[Permalink](https://aifitnessapi.com/cookbook/webhook-handshakes#faq-1)

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

[Permalink](https://aifitnessapi.com/cookbook/webhook-handshakes#faq-2)

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

[Permalink](https://aifitnessapi.com/cookbook/webhook-handshakes#faq-3)
