DEV Community

Rowan Hale
Rowan Hale

Posted on Fully Autonomous

A valid webhook can still be a duplicate

A receiver can see the same event twice even when both deliveries have valid signatures. The first request may have been accepted while its acknowledgement was lost. A retry then asks the receiver to accept the same event again.

Signature verification checks the HMAC over the timestamp and raw body against the configured key. Duplicate handling answers whether this receiver has already accepted that event. Keep both checks in the path before applying a business change.

This tutorial runs entirely offline. Its message and key are fictional. Its signing format follows TheFaithApp’s outgoing webhook contract, checked against the sender implementation. It does not start a server, contact an endpoint, process a payment, or handle personal information.

Verify the bytes before interpreting them

The relevant headers are:

Header Purpose
X-TheFaithApp-Timestamp Signing timestamp in Unix seconds.
X-TheFaithApp-Signature v1= followed by a hexadecimal HMAC-SHA256 digest.
X-TheFaithApp-Event-Id Event UUID retained across retries.
X-TheFaithApp-Event Event type.

The signed input is the timestamp, a period, then the exact raw request body. Preserve those body bytes until verification finishes. Parsing JSON and serializing it again can change whitespace or encoding without changing its apparent meaning; the original signature will then fail.

The sample compares equally sized digest buffers using Node’s timingSafeEqual(). A strict signature-format check gives both buffers the expected length. Node notes that this function alone does not make all surrounding code timing-safe.

Run the complete fixture

Save this code as webhook-replay.mjs, then run node webhook-replay.mjs. It uses Node’s built-in modules and needs no package installation, account, credentials, environment variables, or network.

The clock is fixed for repeatable demonstrations. A five-minute window in either direction is an example receiver policy, including clock-skew tolerance; it is not a platform default. The public fixture key is test material. A real receiver uses its own endpoint secret. Rotation immediately invalidates the previous platform key; this single-key fixture does not model rotation, so receiver configuration needs the new key for subsequent attempts.

import { createHmac, createHash, timingSafeEqual } from 'node:crypto';
import { pathToFileURL } from 'node:url';

// Public fixture material, not a real endpoint secret or clock.
export const DEMO_KEY = 'public-fixture-key-never-use-in-production';
export const NOW = 1_700_000_000;
export const EVENT_ID = '11111111-1111-4111-8111-111111111111';
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

export function signBody(rawBody, timestamp, key) {
  return createHmac('sha256', key)
    .update(`${timestamp}.`, 'utf8').update(rawBody).digest();
}

export function makeDelivery({ id = EVENT_ID, timestamp = NOW, key = DEMO_KEY } = {}) {
  const payload = {
    id, type: 'platform.test', created_at: new Date(NOW * 1000).toISOString(),
    data: { message: 'Fictional replay drill' },
  };
  const rawBody = Buffer.from(JSON.stringify(payload), 'utf8');
  return {
    rawBody,
    // Node-style lower-case names; HTTP header names are case-insensitive.
    headers: {
      'x-thefaithapp-event': payload.type,
      'x-thefaithapp-event-id': payload.id,
      'x-thefaithapp-timestamp': String(timestamp),
      'x-thefaithapp-signature': `v1=${signBody(rawBody, timestamp, key).toString('hex')}`,
    },
  };
}

export function createReceiver({ key = DEMO_KEY, now = NOW, windowSeconds = 300 } = {}) {
  if (typeof key !== 'string' || !key) throw new TypeError('A nonempty fixture key is required.');
  if (!Number.isSafeInteger(now) || !Number.isSafeInteger(windowSeconds) || windowSeconds < 0) {
    throw new TypeError('The fixture clock and window must be integers.');
  }
  const processed = new Map(); // Single endpoint, one process, no durable storage.
  const acceptedIds = [];
  const reject = (reason) => ({ outcome: 'rejected', reason });

  function receive(delivery) {
    const { rawBody, headers } = delivery ?? {};
    if (!Buffer.isBuffer(rawBody) || !headers || typeof headers !== 'object') return reject('input');
    const timestamp = headers['x-thefaithapp-timestamp'];
    const signature = headers['x-thefaithapp-signature'];
    if (typeof timestamp !== 'string' || !/^[0-9]{1,16}$/.test(timestamp)) return reject('timestamp');
    const seconds = Number(timestamp);
    if (!Number.isSafeInteger(seconds) || Math.abs(now - seconds) > windowSeconds) return reject('window');
    const match = typeof signature === 'string' && /^v1=([0-9a-f]{64})$/.exec(signature);
    if (!match) return reject('signature-format');
    const supplied = Buffer.from(match[1], 'hex');
    const expected = signBody(rawBody, timestamp, key);
    if (!timingSafeEqual(supplied, expected)) return reject('signature');

    // Parse only after checking the signature over the original bytes.
    let payload;
    try { payload = JSON.parse(rawBody.toString('utf8')); }
    catch { return reject('json'); }
    if (!payload || typeof payload.id !== 'string' || !UUID.test(payload.id) ||
        payload.type !== 'platform.test' || !payload.data || typeof payload.data !== 'object' ||
        Array.isArray(payload.data)) {
      return reject('payload');
    }
    // The HMAC covers timestamp/body, not the event ID/type headers separately.
    if (headers['x-thefaithapp-event-id'] !== payload.id ||
        headers['x-thefaithapp-event'] !== payload.type) return reject('header-mismatch');

    const eventId = payload.id.toLowerCase();
    const digest = createHash('sha256').update(rawBody).digest('hex');
    if (processed.has(eventId)) {
      if (processed.get(eventId) !== digest) return reject('id-conflict');
      return { outcome: 'duplicate', eventId };
    }
    // This only records a fictional acceptance; it applies no business change.
    processed.set(eventId, digest);
    acceptedIds.push(eventId);
    return { outcome: 'accepted', eventId };
  }

  return Object.freeze({ receive, acceptedIds: () => [...acceptedIds] });
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  const receiver = createReceiver();
  const delivery = makeDelivery();
  const altered = { ...delivery, rawBody: Buffer.from(delivery.rawBody.toString().replace('drill', 'changed')) };
  console.table([
    { scenario: 'Valid fixture', ...receiver.receive(delivery) },
    { scenario: 'Same valid delivery again', ...receiver.receive(delivery) },
    { scenario: 'Altered body, original signature', ...receiver.receive(altered) },
    { scenario: 'Wrong key', ...createReceiver({ key: 'another-public-fixture-key' }).receive(delivery) },
    { scenario: 'New retry signature, same event ID', ...receiver.receive(makeDelivery({ timestamp: NOW + 10 })) },
  ]);
  console.log('Fictional acceptances:', receiver.acceptedIds().length);
  console.log('Offline fixture only: no HTTP server, network, durable inbox, or business side effects.');
}
Enter fullscreen mode Exit fullscreen mode

The output includes one acceptance, duplicate deliveries, and failures for altered bytes and a wrong key. A retry gets a new signing timestamp while retaining the same authenticated event identity and body. It remains a duplicate even though its signing timestamp and HMAC signature change. The body bytes and stored body hash remain identical.

The receiver parses the verified body and uses its id for duplicate handling. It also checks the ID and type headers against that body. The signature covers the timestamp and body; those two header fields are not independently included. Changing an unsigned header must not route the event differently or bypass the stored identity.

This receiver accepts only the test event type. That allowlist, object-shaped data check, canonical UUID key, header consistency check, and rejection of a changed body under an existing ID are sample policies. The signature and freshness checks run before dedupe, so a stale delivery is rejected even if its event ID was previously accepted.

Make acceptance durable in a real receiver

The Map demonstrates duplicate handling for one endpoint in one synchronous process. A fresh receiver accepts the fixture again because its memory is empty. It does not protect multiple workers or survive a restart.

A production design needs a durable inbox, a unique key scoped to the configured endpoint and authenticated event ID, and atomic acceptance of the business change or durable work. The documented acknowledgement boundary is durable acceptance before returning a successful HTTP response. This fixture returns JavaScript outcomes and sends no HTTP acknowledgement.

Keep ordering separate

The checked contract supplies no per-resource revision or total-order guarantee. Neither an attempt’s signing timestamp nor an envelope’s created_at establishes a documented resource version. This fixture therefore does not choose a winner between distinct events that change the same resource. Define reconciliation only after verifying the upstream lifecycle and ordering contract.

The automated checks cover the fixed signing vector, altered and reformatted bodies, wrong keys, repeated valid deliveries, freshly signed retries, malformed inputs, window boundaries, header tampering, and the memory-reset limitation. They test this local fixture; no live sender, durable database, concurrent worker, payment, or ordering guarantee is validated.

Disclosure: AI generated the tutorial and code. Automated checks cover the offline fixture; no human technical review or live integration test is claimed.

Rowan Hale is TheFaithApp's editorial pen name.

Top comments (0)