---
title: "Recipe: A Replay-Safe Health Webhook Receiver"
canonical: "https://aifitnessapi.com/cookbook/webhook-receiver"
cluster: "Cookbook"
primary_query: "idempotent webhook receiver code example"
last_reviewed: "2026-08-12"
description: "A tested, dependency-free ingest function: signature over raw bytes, delivery dedupe, thin pointer jobs, versioned replace, and a DLQ that still acks."
publisher: "AIFitnessAPI — independent, not sponsored"
cite_as: "\"Recipe: A Replay-Safe Health Webhook Receiver\", AIFitnessAPI, https://aifitnessapi.com/cookbook/webhook-receiver"
---

# Recipe: A Replay-Safe Health Webhook Receiver

> A duplicated health webhook does not throw — it lands in an aggregate and quietly doubles somebody's day. This recipe is one ingest function that makes that impossible by construction: it verifies the HMAC over the raw request bytes before anything parses them, dedupes the delivery on its id, enqueues a thin pointer instead of trusting payload values, gates every effect on a monotonic version so an out-of-order delivery is a no-op, and routes a poisoned delivery to a dead-letter queue while still acknowledging. Every collaborator is injected, so the whole replay suite runs with no network and no tunnel. The behaviour is argued in full on the webhook ingestion page; this is the code.

- Canonical: https://aifitnessapi.com/cookbook/webhook-receiver
- Last reviewed: 2026-08-12
- Publisher: AIFitnessAPI (https://aifitnessapi.com) — independent, not sponsored
- Cite as: "Recipe: A Replay-Safe Health Webhook Receiver", AIFitnessAPI, https://aifitnessapi.com/cookbook/webhook-receiver

---

A duplicated health webhook does not throw. It lands in an aggregate, and a doubled calorie total just looks like a good Tuesday — until a support ticket arrives months later and you spend an afternoon proving it was your ingest path rather than the user's watch.

## The shape, in one paragraph

[Designing webhook ingestion for health data](/architecture/webhook-ingestion) argues the whole design; this page is the code that falls out of it. The load-bearing decisions are that the delivery and the effect need **separate** idempotency layers, that the effect must be a replace rather than an increment, and that the endpoint must answer in milliseconds because a slow handler manufactures the very retries it is defending against. The event itself is a thin pointer — most fitness notifications tell you that something about a user, in a window, changed, and nothing more — which is exactly what makes the effect idempotent by construction: re-fetching a window and replacing what you hold is naturally repeatable, and parsing a delta out of a ping never will be. If webhooks themselves are new to you, start at [what webhooks are](/learn/what-are-webhooks).

The one number the endpoint must compute carefully is the day. A civil date stored at ingest and a UTC day derived at read time are not the same thing, and the difference is a daily total that is nine hours wrong for every user in a positive offset — see [whose midnight defines the day](/architecture/timezones-and-day-boundaries).

## The implementation

The canonical file is `cookbook/webhook-receiver.mjs` in this site's repository, and `cookbook/webhook-receiver.test.mjs` runs against it on every CI run. The listing below is a verbatim copy. It has no runtime dependencies beyond `node:crypto`, needs Node 20 or newer, and takes its secrets, stores, queue, dead-letter queue, clock and subject resolver as arguments — so the entire replay suite runs with no network and no tunnel.

```js
/**
 * webhook-receiver.mjs — a health-webhook ingest endpoint that is safe to
 * deliver to twice.
 *
 * WHAT THIS IMPLEMENTS
 *   `ingest(rawBody, headers, deps)` — everything a fitness-provider webhook
 *   endpoint should do before it answers, and nothing it should not:
 *     1. Verify the HMAC signature over the RAW request bytes, BEFORE parsing.
 *        Parsing JSON and re-serializing it breaks signatures over whitespace.
 *        The signature header is a list, so key rotation works.
 *     2. Reject a signed timestamp outside the replay tolerance.
 *     3. Resolve the subject to one of your users. An unresolvable subject is
 *        acknowledged and dropped with NO row written anywhere — including the
 *        DLQ — because an erased user must not be resurrected by a late event.
 *     4. Dedupe the DELIVERY on (provider, delivery id).
 *     5. Treat the event as a thin POINTER. The job carries
 *        (user, provider, metric, window, version) and never a value read out
 *        of the payload; the worker re-fetches the window from the provider.
 *     6. Gate on a monotonic version so an out-of-order delivery is a no-op
 *        instead of a regression. Never resolve on arrival time.
 *     7. Route a handler exception to a DLQ carrying the window, and still ACK.
 *   The endpoint does no provider I/O: acknowledging in milliseconds is what
 *   stops you from manufacturing the retries you are defending against.
 *
 * PATTERN DOCUMENTED AT
 *   https://aifitnessapi.com/architecture/webhook-ingestion
 *   https://aifitnessapi.com/test/webhooks-locally
 *
 * Signature scheme follows the Standard Webhooks shape
 * (`msg_id.timestamp.payload`, a space-delimited `v1,<sig>` list). Real
 * providers differ — swap `hmac`, `headerNames` and `parsePointer` per provider
 * and write your fixtures from a captured delivery, not from a spec example.
 *
 * MIT — from aifitnessapi.com/cookbook
 */
import { createHmac, timingSafeEqual } from "node:crypto";

export const DEFAULT_HEADER_NAMES = {
  id: "webhook-id",
  timestamp: "webhook-timestamp",
  signature: "webhook-signature",
};

/** Standard Webhooks defines no replay window. Pick one; make it a parameter. */
const DEFAULT_TOLERANCE_MS = 5 * 60 * 1000;

function toBytes(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 Buffer, Uint8Array, or string");
}

/** Works with a plain object, a Map, or a WHATWG Headers instance. */
function headerReader(headers) {
  if (headers && typeof headers.get === "function") return (name) => headers.get(name);
  const lower = new Map(
    Object.entries(headers ?? {}).map(([k, v]) => [k.toLowerCase(), Array.isArray(v) ? v[0] : v]),
  );
  return (name) => lower.get(name.toLowerCase()) ?? null;
}

/**
 * Default HMAC-SHA256, base64 out. Standard Webhooks secrets look like
 * `whsec_<base64>` and the KEY is the decoded base64, not the literal string.
 */
export function defaultHmacSha256(secret, messageBytes) {
  const material = String(secret).startsWith("whsec_") ? String(secret).slice(6) : String(secret);
  let key;
  try {
    key = Buffer.from(material, "base64");
    if (key.length === 0) key = Buffer.from(material, "utf8");
  } catch {
    key = Buffer.from(material, "utf8");
  }
  return createHmac("sha256", key).update(messageBytes).digest("base64");
}

function constantTimeEquals(a, b) {
  const ba = Buffer.from(String(a));
  const bb = Buffer.from(String(b));
  if (ba.length !== bb.length) return false;
  return timingSafeEqual(ba, bb);
}

/**
 * Verify a signature over the exact bytes received.
 *
 * The header carries a space-delimited LIST so a producer can sign with the
 * old and the new secret during a rotation. Accepting only the first entry
 * passes the happy path for months and fails on rotation morning.
 */
export function verifySignature({ rawBody, id, timestamp, signatureHeader, secrets, hmac = defaultHmacSha256 }) {
  if (!signatureHeader || !Array.isArray(secrets) || secrets.length === 0) return false;
  const signed = Buffer.concat([Buffer.from(`${id}.${timestamp}.`, "utf8"), toBytes(rawBody)]);
  const presented = String(signatureHeader)
    .split(/\s+/)
    .filter(Boolean)
    .map((part) => (part.includes(",") ? part.slice(part.indexOf(",") + 1) : part));

  let matched = false;
  for (const secret of secrets) {
    const expected = hmac(secret, signed);
    for (const candidate of presented) {
      // No early return: keep the work constant across secrets and signatures.
      if (constantTimeEquals(expected, candidate)) matched = true;
    }
  }
  return matched;
}

/**
 * Turn a decoded event into the pointer the fetch worker needs. This is the
 * one function you rewrite per provider — the envelope below is a neutral
 * shape, not any real vendor's.
 */
export function defaultParsePointer(event) {
  const subjectId = event?.subject_id ?? event?.ownerId ?? event?.owner_id ?? event?.userId;
  if (subjectId === undefined || subjectId === null || subjectId === "") {
    throw new Error("event carries no subject id");
  }
  const metric = event?.metric ?? event?.collectionType ?? "unknown";
  const windowStart = event?.window_start ?? event?.date ?? null;
  if (!windowStart) throw new Error("event carries no window");
  const version = Number(event?.version ?? event?.modified_at ?? 0);
  if (!Number.isFinite(version)) throw new Error("event carries an unusable version");
  return {
    subjectId: String(subjectId),
    metric: String(metric),
    windowStart: String(windowStart),
    windowEnd: event?.window_end ? String(event.window_end) : String(windowStart),
    version,
  };
}

/** The debounce key. Eleven pings about the same Tuesday collapse into one job. */
export function jobKeyFor({ provider, userId, metric, windowStart }) {
  return `${provider}:${userId}:${metric}:${windowStart}`;
}

const REJECT = (outcome, detail) => ({ status: 400, ack: false, outcome, detail });
const ACK = (outcome, extra = {}) => ({ status: 200, ack: true, outcome, ...extra });

/**
 * @param {Buffer|Uint8Array|string} rawBody  EXACTLY the bytes the provider sent.
 * @param {object|Headers} headers
 * @param {object} deps
 * @param {string[]} deps.secrets            all currently-valid secrets (rotation)
 * @param {(subjectId: string) => Promise<string|null>} deps.resolveSubject
 *        Must be driven by a tombstone, not by "no user row" — an ingest path
 *        that reads a missing user as "create one" passes against an empty test
 *        database and resurrects an erased person against a real one.
 * @param {{insertIfAbsent: (key: string, row: object) => Promise<boolean>}} deps.seen
 * @param {{commitVersion: (key: string, version: number) => Promise<boolean>}} deps.pointers
 * @param {{enqueue: (job: object) => Promise<void>}} deps.queue
 * @param {{add: (entry: object) => Promise<void>}} deps.dlq
 * @param {string} [deps.provider]
 * @param {(secret: string, bytes: Buffer) => string} [deps.hmac]
 * @param {(bytes: Buffer) => object} [deps.parseBody]
 * @param {(event: object, headers: object) => object} [deps.parsePointer]
 * @param {() => number} [deps.now]
 * @param {number} [deps.toleranceMs]
 * @param {object} [deps.headerNames]
 */
export async function ingest(rawBody, headers, deps) {
  const {
    secrets,
    resolveSubject,
    seen,
    pointers,
    queue,
    dlq,
    provider = "unknown",
    hmac = defaultHmacSha256,
    parseBody = (bytes) => JSON.parse(bytes.toString("utf8")),
    parsePointer = defaultParsePointer,
    now = () => Date.now(),
    toleranceMs = DEFAULT_TOLERANCE_MS,
    headerNames = DEFAULT_HEADER_NAMES,
  } = deps ?? {};

  const bytes = toBytes(rawBody);
  const read = headerReader(headers);
  const deliveryId = read(headerNames.id);
  const timestamp = read(headerNames.timestamp);
  const signatureHeader = read(headerNames.signature);

  if (!deliveryId || !timestamp) return REJECT("missing-headers");

  // ---- 1. Signature, over the raw bytes, before anything parses them. ------
  if (!verifySignature({ rawBody: bytes, id: deliveryId, timestamp, signatureHeader, secrets, hmac })) {
    return REJECT("invalid-signature");
  }

  // ---- 2. Replay tolerance. A correctly signed delivery from last week is
  //         still a replay. Checked before dedupe so it costs no storage. ----
  const signedAtMs = Number(timestamp) * 1000;
  if (!Number.isFinite(signedAtMs) || Math.abs(now() - signedAtMs) > toleranceMs) {
    return REJECT("stale-timestamp");
  }

  let pointer = null;
  let userId = null;
  try {
    // ---- 3. Parse, then resolve the subject. ------------------------------
    const event = parseBody(bytes);
    pointer = parsePointer(event, headers);

    userId = await resolveSubject(pointer.subjectId);
    if (!userId) {
      // Acknowledge so the provider stops retrying, and write NOTHING — not a
      // delivery row, not a DLQ row. Then go revoke the subscription: the row
      // is not the problem, the live subscription is.
      return ACK("unresolved-subject", { revokeSubscription: true, subjectId: pointer.subjectId });
    }

    // ---- 4. Delivery-level dedupe. A duplicate is a SUCCESS: the provider
    //         delivered and we acknowledged. Answering non-2xx here converts
    //         one duplicate into an escalating retry storm. -----------------
    const deliveryKey = `${provider}:${deliveryId}`;
    const fresh = await seen.insertIfAbsent(deliveryKey, {
      provider,
      deliveryId,
      userId,
      metric: pointer.metric,
      windowStart: pointer.windowStart,
      signedAtMs,
      receivedAtMs: now(),
    });
    if (!fresh) return ACK("duplicate", { deliveryId });

    // ---- 5/6. Versioned gate on the pointer. A backlog flush delivers
    //           two-day-old events after fresh ones; ordering on arrival would
    //           overwrite a correct day with a stale one, silently. ---------
    const key = jobKeyFor({ provider, userId, metric: pointer.metric, windowStart: pointer.windowStart });
    const advanced = await pointers.commitVersion(key, pointer.version);
    if (!advanced) return ACK("stale-version", { deliveryId, key, version: pointer.version });

    // ---- 7. Enqueue a thin pointer. No value from the payload travels with
    //         it; the worker re-reads the window and does a versioned replace,
    //         which is idempotent by construction. ------------------------
    const job = {
      key,
      provider,
      userId,
      metric: pointer.metric,
      windowStart: pointer.windowStart,
      windowEnd: pointer.windowEnd,
      version: pointer.version,
      deliveryId,
    };
    await queue.enqueue(job);
    return ACK("enqueued", { deliveryId, job });
  } catch (error) {
    // ACK DECISION, DELIBERATE: a poisoned delivery is acknowledged (200), not
    // 500'd. The signature already proved the sender; replaying the same bytes
    // will hit the same defect, so a non-2xx only buys an escalating retry
    // storm and, on platforms with a disable policy, an endpoint the provider
    // eventually stops talking to. Recovery is a DLQ replay that RE-PULLS the
    // window against a fixed adapter — which is why the window, not just the
    // body, is what we store.
    await dlq.add({
      provider,
      deliveryId,
      userId,
      metric: pointer?.metric ?? null,
      windowStart: pointer?.windowStart ?? null,
      windowEnd: pointer?.windowEnd ?? null,
      rawBody: bytes,
      error: String(error?.message ?? error),
      failedAtMs: now(),
    });
    return ACK("dead-lettered", { deliveryId, error: String(error?.message ?? error) });
  }
}

/**
 * The write the fetch worker performs after re-reading the window. REPLACE,
 * never increment, gated on a monotonic version — the SQL equivalent is
 * `on conflict ... do update set value = excluded.value
 *  where excluded.version > daily_rollup.version`.
 *
 * @returns {Promise<boolean>} true when the row moved.
 */
export async function applyVersionedReplace(store, key, { value, version, ...rest }) {
  const current = await store.read(key);
  if (current && Number(current.version) >= Number(version)) return false;
  await store.write(key, { ...rest, value, version: Number(version) });
  return true;
}

/**
 * Reference seen-store. In Postgres this is
 * `insert into webhook_delivery ... on conflict do nothing` and the boolean is
 * the row count. Rows must live at least as long as the provider's retry
 * horizon — and must be purged with the user on erasure, or a late delivery
 * re-materializes somebody you deleted.
 */
export function createMemorySeenStore() {
  const rows = new Map();
  return {
    async insertIfAbsent(key, row) {
      if (rows.has(key)) return false;
      rows.set(key, row);
      return true;
    },
    get size() {
      return rows.size;
    },
    rows,
  };
}

/** Reference version gate. In Postgres: an upsert with a `where excluded.version > ...`. */
export function createMemoryVersionGate() {
  const versions = new Map();
  return {
    async commitVersion(key, version) {
      const current = versions.get(key);
      if (current !== undefined && current >= version) return false;
      versions.set(key, version);
      return true;
    },
    async readVersion(key) {
      return versions.get(key);
    },
    versions,
  };
}
```

## How to adapt it

**Three functions are provider-specific, and they are separated for that reason.** `defaultHmacSha256` implements one scheme; yours may differ, and some vendors' schemes are not publicly documented, so read the provider's docs and write your fixture from a captured delivery rather than from a spec example. `DEFAULT_HEADER_NAMES` carries the id, timestamp and signature header names, which vary. `defaultParsePointer` reads a neutral envelope that is nobody's real payload — replace it with one parser per provider, and keep it throwing on a missing subject or window, because a pointer you cannot build is a delivery that belongs in the dead-letter queue.

**Make `resolveSubject` tombstone-driven.** It must return null for a user who was erased, not merely for a user who is absent. An ingest path that reads "no such user" as "create one" passes happily against an empty test database and resurrects a real erased person in production.

**Replace the two reference stores.** `createMemorySeenStore` is an `INSERT ... ON CONFLICT DO NOTHING` and the boolean is the affected row count. `createMemoryVersionGate` is an upsert whose update clause is gated on the incoming version being the larger one. Both belong in your database, not in process memory, and both need a retention policy: the delivery rows must outlive the provider's maximum retry horizon, and they must be purged in the same job that erases the user, or a late delivery re-materializes somebody you deleted.

**Pick a replay tolerance and make it a parameter.** The five-minute default here is a choice, not a specification. Put yours in config and make the boundary a test parameter rather than a constant buried in the verifier.

**Keep the signature loop, even before you rotate.** The header is a list precisely so a producer can sign with the old and the new secret at once. A verifier written against a single signature passes for months and fails on the morning you rotate.

**Run a reconciliation sweep anyway.** At-least-once delivery is a promise about events that get sent. It says nothing about the events that were never sent because your endpoint was failing during a deploy. This file makes each delivery safe; it cannot make a missing delivery appear.

## The contract the tests hold it to

Each bullet is one test in `cookbook/webhook-receiver.test.mjs`. Every assertion is about enqueued jobs and stored state, never about a status code on its own — a duplicate delivery answers 200 too, so the status code cannot tell you whether the second one did nothing or did everything twice.

- A bad signature is rejected with a 400 and no acknowledgement, and the injected body parser is **never called**: the bytes are rejected before anything reads them. Nothing is written to the queue, the dedupe table, or the dead-letter queue.
- Verification is over the raw bytes: a body that is parsed and re-serialized with one stray byte no longer verifies, which is the whole reason to hold the original buffer.
- During a rotation, a delivery signed with both the old and the new secret is accepted; once the old secret is dropped from the configured list, a delivery signed only with it is rejected.
- A correctly signed delivery from three days ago is rejected on the timestamp tolerance, before it costs a dedupe row.
- A byte-identical replay of the same delivery id is acknowledged with a 200 and enqueues **nothing** — one job, not two.
- The enqueued job is a thin pointer: user, provider, metric, window, version, delivery id. The payload's own value field does not appear on it.
- An older version arriving after a newer one is acknowledged, enqueues nothing, and leaves the stored version where it was. The backlog flush cannot regress the day.
- A genuine re-notification for the same day with a higher version **does** enqueue again, because a day that fills in is a real update rather than a duplicate.
- An unresolvable subject is acknowledged and dropped with no row written anywhere, including the dead-letter queue, and the result asks the caller to revoke the subscription rather than fix a row.
- A handler exception dead-letters an entry carrying the user, the metric, the window and the verified bytes, and still returns 200. The reasoning for that acknowledgement is written into the `catch` block itself.
- A malformed body with a valid signature is dead-lettered with a null window rather than a partial write.
- `applyVersionedReplace` replaces rather than increments, ignores a stale version, and treats an equal version as a no-op — so 2,100 steps followed by 8,431 steps is 8,431, and never 10,531.

## What it deliberately does not do

It does not talk to the provider. That belongs in the fetch worker, behind its own rate-limit handling, precisely so a provider's bad day cannot slow your endpoint into generating more retries. It does not implement the subscription handshake, which is a one-time setup event with the loudest failure mode in the whole area — nothing arrives at all — and is covered in [testing webhooks locally](/test/webhooks-locally). And it cannot recover a deletion it never heard about: whether you can act on a provider's deletion signal is a schema decision made months before the first one arrives.

## FAQ

### Why acknowledge a delivery that blew up inside the handler?

Because replaying the same bytes hits the same defect. The signature already proved who sent it, so answering with a 500 buys nothing except an escalating retry storm — and on platforms with an endpoint-disable policy, eventually an endpoint the provider stops talking to. The delivery goes to a dead-letter queue carrying the affected window, and recovery is a replay that re-pulls that window against a fixed adapter rather than re-applying a stored payload.

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

### Why does the enqueued job carry a window instead of the numbers from the payload?

Most fitness notifications are change pointers rather than data carriers, so the values in the body are either absent or untrustworthy. Keying the job on user, provider, metric and window makes the effect idempotent by construction: re-fetching a window and replacing what you hold is naturally repeatable, while parsing a delta out of a ping and applying it never will be. It also collapses eleven pings about the same Tuesday into one provider call.

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

### Which parts of this file will I have to rewrite for my provider?

Three, and they are isolated for exactly that reason. The hmac function, because signature schemes differ per vendor and some are not publicly documented. The header names, because the id, timestamp and signature headers are vendor-specific. And parsePointer, because only you know how your provider names its subject id, metric and window. Write the fixture from a real captured delivery rather than from a spec example, and the rest of the file stays as it is.

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

### What stops a late delivery from resurrecting a user who asked to be erased?

Subject resolution happens before any store is touched, and an unresolvable subject is acknowledged and dropped with no row written anywhere — not a delivery row, not a dead-letter row. The dead-letter queue is a store like any other and it is the one teams forget. The resolver must also be driven by a tombstone rather than by a missing user row, or an ingest path that reads absence as create-one will pass against an empty test database and resurrect a real erased person in production.

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