---
title: "Recipe: Verify Oura, WHOOP, Terra and Polar Webhook Signatures"
canonical: "https://aifitnessapi.com/cookbook/vendor-webhook-signatures"
cluster: "Cookbook"
primary_query: "verify oura whoop terra polar webhook signature node"
last_reviewed: "2026-11-05"
description: "Tested Node code that checks Oura, WHOOP, Terra and Polar webhook signatures the way each vendor documents, cross-checked against the site's verifier."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: Verify Oura, WHOOP, Terra and Polar Webhook Signatures\", AIFitnessAPI, https://aifitnessapi.com/cookbook/vendor-webhook-signatures"
---

# Recipe: Verify Oura, WHOOP, Terra and Polar Webhook Signatures

> A copy-and-run Node module that checks a webhook signature from Oura, WHOOP, Terra or Polar on your server, each the way the vendor's own page describes it. All four use HMAC-SHA256, and no two sign the same string: Oura signs the timestamp header plus JSON.stringify of the parsed body and sends upper-case hex, WHOOP signs the millisecond timestamp plus the raw body and sends base64, Terra signs timestamp, a period and the raw body inside a t=…,v1=… header, and Polar signs the raw body alone. Its test suite signs with this file and verifies with the module behind /tools/webhook-verifier, and the other way round, for every vendor.

- Canonical: https://aifitnessapi.com/cookbook/vendor-webhook-signatures
- Last reviewed: 2026-11-05
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: Verify Oura, WHOOP, Terra and Polar Webhook Signatures", AIFitnessAPI, https://aifitnessapi.com/cookbook/vendor-webhook-signatures

---

Four fitness APIs sign their webhooks with HMAC-SHA256, and a receiver that treats them as one scheme verifies none of them. The [webhook verifier](/tools/webhook-verifier) quotes each vendor's rule from its own page, read on October 31, 2026; this recipe is the same four rules as server code, and its tests hold the two together so that a fix to one cannot silently leave the other behind. For the generic shape of a receiver (dedupe, pointer jobs, a dead-letter queue), see [the replay-safe webhook receiver](/cookbook/webhook-receiver).

## What each vendor signs

| Vendor | Header | Signed string | Key | Encoding |
|---|---|---|---|---|
| Oura | `x-oura-signature`, `x-oura-timestamp` | timestamp + `JSON.stringify(body)` | client secret | hex, upper-case |
| WHOOP | `X-WHOOP-Signature`, `X-WHOOP-Signature-Timestamp` | timestamp + raw body | app client secret | base64 |
| Terra | `terra-signature: t=…,v1=…` | `t` + `.` + raw body | endpoint signing secret | hex; check every `v1` |
| Polar | `Polar-Webhook-Signature` | raw body | `signature_secret_key` from webhook creation | hex |

WHOOP documents its timestamp as "the milliseconds since epoch timestamp used to verify the signature". Terra says to "ignore all schemes that aren't v1" and to use "a constant-time-string comparison to compare the expected signature to each of the received signatures". Strava, Withings, Wahoo and the Google Health API do not sign their events at all; their checks are a handshake or a shared secret, and [the handshake recipe](/cookbook/webhook-handshakes) and [the Withings router](/cookbook/withings-notification-router) cover them.

## What the recipe does

- **Takes the raw body.** `rawBody` must be the bytes as they arrived; a string is accepted only because some frameworks hand you one. Only Oura's construction re-serializes, and the recipe does that itself.
- **Accepts a list of secrets.** During a secret rotation you verify against old and new for a while; every candidate is compared in constant time with `timingSafeEqual` over equal-length buffers.
- **Reports why, never throws on a bad request.** Missing header, malformed Terra header, body that is not JSON (Oura), mismatch, stale timestamp: each is a stable `reason` string your logs can count.
- **Applies a replay window only where it can.** No vendor here states a tolerance, so `maxAgeMs` is off by default and applies only to a timestamp whose unit is documented (WHOOP) or that you declare.
- **Signs, too.** `signLikeVendor` produces the headers a vendor would send, for fixtures and local replay scripts.

## The implementation

The canonical file is `cookbook/vendor-webhook-signatures.mjs` in this site's repository, and `cookbook/vendor-webhook-signatures.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 does no I/O: bytes, headers and secrets go in, a verdict comes out.

```js
/**
 * vendor-webhook-signatures.mjs — check an Oura, WHOOP, Terra or Polar
 * webhook signature on your server, each the way that vendor documents it.
 *
 * WHAT THIS IMPLEMENTS
 *   `verifyVendorSignature(vendor, { rawBody, headers, secret })` returns
 *   `{ ok, reason, ageMs }`. Four vendors sign with HMAC-SHA256, and no two
 *   sign the same string:
 *     oura   x-oura-signature = HEX_UPPER(HMAC(client secret,
 *              x-oura-timestamp + JSON.stringify(JSON.parse(body))))
 *     whoop  X-WHOOP-Signature = BASE64(HMAC(client secret,
 *              X-WHOOP-Signature-Timestamp + raw body)); the timestamp is
 *              milliseconds since epoch
 *     terra  terra-signature: t=<ts>,v1=<sig>[,v1=<sig>...]; HEX(HMAC(signing
 *              secret, t + "." + raw body)), compared with every v1 value;
 *              every other scheme is ignored
 *     polar  Polar-Webhook-Signature = HEX(HMAC(signature_secret_key, raw
 *              body)); hex case is compared case-insensitively because
 *              Polar's sample does not state a case
 *   Every comparison is constant-time over equal-length buffers. Secrets are
 *   a list so a rotation can accept the old and new secret for a while.
 *
 *   Replay window: none of the four pages states a tolerance, so `maxAgeMs`
 *   is yours to choose and is off by default. It is applied only where the
 *   timestamp's unit is documented (WHOOP's milliseconds); for Oura and Terra
 *   pass `timestampUnit` yourself once you have confirmed it from a captured
 *   delivery.
 *
 * WHERE THE RULES COME FROM
 *   Each rule is the one https://aifitnessapi.com/tools/webhook-verifier
 *   quotes from the vendor's own page (read 2026-10-31). The test suite signs
 *   with this file and verifies with that tool's module, and the other way
 *   round, so the two cannot drift apart silently.
 *
 * No dependencies. Node 20+.
 *
 * MIT — from aifitnessapi.com/cookbook
 */
import { createHmac, timingSafeEqual } from "node:crypto";

/** Why a request failed, as stable strings a caller can switch on. */
export const REASON = Object.freeze({
  OK: "ok",
  MISSING_HEADER: "missing-header",
  MALFORMED_HEADER: "malformed-header",
  BODY_NOT_JSON: "body-not-json",
  MISMATCH: "mismatch",
  STALE: "stale-timestamp",
  UNKNOWN_VENDOR: "unknown-vendor",
  NO_SECRET: "no-secret",
});

/**
 * The documented scheme per vendor. `timestampUnit` is filled in only where
 * the vendor's page states it.
 */
export const SCHEMES = Object.freeze({
  oura: { signatureHeader: "x-oura-signature", timestampHeader: "x-oura-timestamp", encoding: "hex-upper", timestampUnit: null },
  whoop: { signatureHeader: "x-whoop-signature", timestampHeader: "x-whoop-signature-timestamp", encoding: "base64", timestampUnit: "ms" },
  terra: { signatureHeader: "terra-signature", timestampHeader: null, encoding: "hex", timestampUnit: null },
  polar: { signatureHeader: "polar-webhook-signature", timestampHeader: null, encoding: "hex-any-case", timestampUnit: null },
});

function toBuffer(raw) {
  if (Buffer.isBuffer(raw)) return raw;
  if (raw instanceof Uint8Array) return Buffer.from(raw.buffer, raw.byteOffset, raw.byteLength);
  if (typeof raw === "string") return Buffer.from(raw, "utf8");
  throw new TypeError("rawBody must be a Buffer, Uint8Array or string: capture it before any JSON parser runs");
}

/** Plain object, Map or WHATWG Headers; names are case-insensitive. */
export function readHeader(headers, name) {
  if (!headers) return null;
  if (typeof headers.get === "function") return headers.get(name) ?? headers.get(name.toLowerCase()) ?? null;
  for (const [k, v] of Object.entries(headers)) {
    if (k.toLowerCase() === name.toLowerCase()) return Array.isArray(v) ? v[0] : String(v);
  }
  return null;
}

/** Constant-time equality of two strings, false on any length difference. */
export function constantTimeEqual(a, b) {
  const x = Buffer.from(String(a), "utf8");
  const y = Buffer.from(String(b), "utf8");
  if (x.length !== y.length) return false;
  return timingSafeEqual(x, y);
}

function hmac(secret, parts) {
  const h = createHmac("sha256", secret);
  for (const p of parts) h.update(p);
  return h.digest();
}

/** Parse `t=...,v1=...,v1=...`; other schemes are dropped (Terra: ignore all but v1). */
export function parseTerraHeader(value) {
  let t = null;
  const v1 = [];
  for (const part of String(value).split(",")) {
    const i = part.indexOf("=");
    if (i <= 0) continue;
    const k = part.slice(0, i).trim();
    const val = part.slice(i + 1).trim();
    if (k === "t") t = val;
    else if (k === "v1" && val) v1.push(val);
  }
  return t && v1.length ? { t, v1 } : null;
}

/** The expected signature(s) for one secret, as the strings the header carries. */
export function expectedSignature(vendor, { rawBody, timestamp, secret }) {
  const body = toBuffer(rawBody);
  switch (vendor) {
    case "oura": {
      // Oura's sample signs JSON.stringify of the parsed body, not the bytes.
      const reserialized = JSON.stringify(JSON.parse(body.toString("utf8")));
      return hmac(secret, [String(timestamp), reserialized]).toString("hex").toUpperCase();
    }
    case "whoop":
      return hmac(secret, [String(timestamp), body]).toString("base64");
    case "terra":
      return hmac(secret, [String(timestamp), ".", body]).toString("hex");
    case "polar":
      return hmac(secret, [body]).toString("hex");
    default:
      throw new Error(`unknown vendor ${vendor}`);
  }
}

function ageOf(timestamp, unit, nowMs) {
  if (!unit) return null;
  const n = Number(timestamp);
  if (!Number.isFinite(n)) return null;
  return nowMs - (unit === "s" ? n * 1000 : n);
}

/**
 * @param {"oura"|"whoop"|"terra"|"polar"} vendor
 * @param {{ rawBody: Buffer|Uint8Array|string, headers: object, secret: string|string[],
 *           maxAgeMs?: number, timestampUnit?: "s"|"ms", now?: () => number }} input
 * @returns {{ ok: boolean, reason: string, ageMs: number|null }}
 */
export function verifyVendorSignature(vendor, { rawBody, headers, secret, maxAgeMs = null, timestampUnit, now = Date.now }) {
  const scheme = SCHEMES[vendor];
  if (!scheme) return { ok: false, reason: REASON.UNKNOWN_VENDOR, ageMs: null };
  const secrets = (Array.isArray(secret) ? secret : [secret]).filter((s) => typeof s === "string" && s.length > 0);
  if (!secrets.length) return { ok: false, reason: REASON.NO_SECRET, ageMs: null };

  const sigHeader = readHeader(headers, scheme.signatureHeader);
  if (!sigHeader) return { ok: false, reason: REASON.MISSING_HEADER, ageMs: null };

  let timestamp = null;
  let received = [sigHeader.trim()];
  if (vendor === "terra") {
    const parsed = parseTerraHeader(sigHeader);
    if (!parsed) return { ok: false, reason: REASON.MALFORMED_HEADER, ageMs: null };
    timestamp = parsed.t;
    received = parsed.v1;
  } else if (scheme.timestampHeader) {
    timestamp = readHeader(headers, scheme.timestampHeader);
    if (!timestamp) return { ok: false, reason: REASON.MISSING_HEADER, ageMs: null };
  }

  const ageMs = ageOf(timestamp, timestampUnit ?? scheme.timestampUnit, now());
  if (maxAgeMs != null && ageMs != null && Math.abs(ageMs) > maxAgeMs) {
    return { ok: false, reason: REASON.STALE, ageMs };
  }

  for (const s of secrets) {
    let expected;
    try {
      expected = expectedSignature(vendor, { rawBody, timestamp, secret: s });
    } catch (err) {
      if (err instanceof SyntaxError) return { ok: false, reason: REASON.BODY_NOT_JSON, ageMs };
      throw err;
    }
    for (const r of received) {
      const candidate = scheme.encoding === "hex-any-case" || scheme.encoding === "hex" ? r.toLowerCase() : r;
      const want = scheme.encoding === "hex-any-case" ? expected.toLowerCase() : expected;
      if (constantTimeEqual(candidate, want)) return { ok: true, reason: REASON.OK, ageMs };
    }
  }
  return { ok: false, reason: REASON.MISMATCH, ageMs };
}

/**
 * Sign a body the way a vendor would, for fixtures and local replay tools.
 * Returns the headers the vendor would send.
 */
export function signLikeVendor(vendor, { rawBody, secret, timestamp }) {
  const sig = expectedSignature(vendor, { rawBody, timestamp, secret });
  switch (vendor) {
    case "oura":
      return { "x-oura-signature": sig, "x-oura-timestamp": String(timestamp) };
    case "whoop":
      return { "X-WHOOP-Signature": sig, "X-WHOOP-Signature-Timestamp": String(timestamp) };
    case "terra":
      return { "terra-signature": `t=${timestamp},v1=${sig}` };
    case "polar":
      return { "Polar-Webhook-Signature": sig };
    default:
      throw new Error(`unknown vendor ${vendor}`);
  }
}
```

## How to adapt it

**Capture the raw body first.** In Express use `express.raw({ type: "application/json" })` on the webhook route; in a Next.js route handler read `await request.arrayBuffer()` before anything parses it. A body that went through a JSON parser and back is the most common reason a genuine delivery fails.

**Keep secrets per vendor and per environment.** Polar returns `signature_secret_key` once, when the webhook is created; store it then or you will have to recreate the webhook.

**Answer fast.** Verification is microseconds, but the vendors' time limits are short: WHOOP recommends returning a 2XX within a second, Oura under 10 seconds, Terra waits 8 seconds. Verify, enqueue, return.

## The contract the tests hold it to

For each of the four vendors: a body signed with the secret verifies; one changed byte, a wrong secret and a missing header each fail with the right reason; a rotated secret list containing the right secret passes; the site's verifier accepts what this file signs; and this file accepts the example the site's verifier builds from the vendor's documentation. Beyond that, a stale WHOOP timestamp is refused only when a window is set, Oura and Terra get no window until you name the unit, a pretty-printed Oura body still verifies while re-serialized WHOOP, Terra and Polar bodies do not, Terra tries every `v1` and treats a `v0`-only header as malformed, and Polar hex compares in either case.

## What it deliberately does not do

It does not dedupe deliveries, resolve users or fetch data; [the webhook receiver](/cookbook/webhook-receiver) does those. It does not check the Google Health API's `GOOGLE-HEALTH-API-SIGNATURE` header: Google's webhooks page, read November 6, 2026, documents it as a Tink ECDSA P-256 signature over the raw JSON body with keys published as a JSON keyset, a different construction from these four HMACs, and [the webhook verifier](/tools/webhook-verifier) checks it against that keyset. And it does not cover Garmin, whose public page states no signing rule.

## FAQ

### Why does an Oura webhook still verify after my framework reformats the JSON body?

Because Oura's own sample signs JSON.stringify of the parsed body, not the bytes that arrived: hmac.update(timestamp + JSON.stringify(body)). Re-serializing the parsed object gives the same string whatever whitespace the request carried, so a pretty-printed copy of the body still verifies, and the recipe's test checks exactly that. The flip side is that the check depends on Oura's serializer and JavaScript's agreeing on key order and escaping; if they ever differ, a genuine delivery fails. WHOOP, Terra and Polar sign the raw bytes instead, where reformatting is fatal, so capture the raw body for those before any JSON middleware runs.

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

### Which of these four vendors states a replay window for webhook timestamps?

None of the four pages the site's webhook verifier quotes states one, as read on October 31, 2026. WHOOP documents that X-WHOOP-Signature-Timestamp is milliseconds since epoch, so a window can be applied to it safely; Oura and Terra sign a timestamp without saying its unit on the lines quoted, and Polar signs no timestamp at all. The recipe therefore leaves the window off by default, applies maxAgeMs only where the unit is documented or where you pass timestampUnit yourself, and returns the age it computed so you can choose a number from real deliveries rather than guess one.

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

### How should a Terra webhook with several v1 signatures be checked?

Compare your computed signature against every v1 value in the terra-signature header and accept the request if any one matches, using a constant-time comparison each time; Terra's page says to do exactly that to protect against timing attacks. Ignore every other scheme, such as a v0 entry, because Terra tells you to discard schemes that are not v1 to prevent downgrade attacks. The recipe parses t= and every v1= pair, drops the rest, and reports a header with no v1 at all as malformed rather than as a mismatch, which keeps a downgrade attempt distinguishable from a wrong secret in your logs.

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

### Should Polar webhook signatures be compared as upper-case or lower-case hex?

Either, because Polar's sample does not say. Its documentation returns toHexString(mac.doFinal(data.getBytes())) without stating a case, and its example header happens to be lower-case. Comparing case-insensitively costs nothing in security, since a hex digest carries the same bits in either case, and it removes a failure mode that would look exactly like a wrong secret. The recipe lower-cases both sides for Polar and for Terra, and compares Oura's exactly, because Oura's sample explicitly calls toUpperCase().

[Permalink](https://aifitnessapi.com/cookbook/vendor-webhook-signatures#faq-4)
