> ## Documentation Index
> Fetch the complete documentation index at: https://docs.thefaithapp.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Local Webhook Receiver Example

> Copy a dependency-free Node.js receiver and test signed, repeat-safe acceptance with fictional events.

Practice the [signed webhook contract](/api-reference/platform-access-and-webhooks#verify-a-delivery)
without creating an endpoint or sending a production delivery. This example
verifies exact raw bytes, saves the complete event before responding, and
recognizes a retry after a restart.

## Run locally

Use Node.js 22 or later on macOS or Linux with a local filesystem that supports
file and directory sync. Create an empty folder and copy all three files below
into it using their displayed names. No package installation is needed.

```sh theme={null}
node --test receiver.test.mjs
export TFA_WEBHOOK_SECRET="$(node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("hex"))')"
node receiver.mjs
```

In a second terminal, set the same **local** secret in
`TFA_WEBHOOK_SECRET` and run `node fixture.mjs`. The sender only contacts
`http://127.0.0.1:8787/webhooks/thefaithapp`. First delivery returns
`202 {"accepted":true,"duplicate":false}`. Repeat it to receive
`200 {"accepted":true,"duplicate":true}`. Stop and restart the receiver in
the same folder, then repeat the fixture to check that the saved event is
recognized. Inspect `inbox/` to see the complete fictional event.

<Note>
  This loopback HTTP URL is for the local fixture only. It cannot be registered
  as a TheFaithApp endpoint. A real endpoint must use publicly reachable HTTPS.
</Note>

## What acceptance means

The receiver signs nothing itself: it calculates the expected HMAC-SHA256 over
the timestamp, a period, and the exact incoming bytes, then compares with
`timingSafeEqual`. Header event ID and type must match the signed body.
Each fixture retry has a fresh timestamp and the same event ID and body.

A single process serializes acceptance, writes a restricted-permission file,
syncs it, renames it into the inbox and syncs the directory before returning
`202`. An identical saved event returns `200` after its file and directory
are synced again. Changed signed content with the same ID returns `409`;
storage failure returns `503` so the sender can retry. The five-minute
timestamp window and 1 MiB body limit are example policies, not TheFaithApp
server settings. Keep the receiver clock synchronized.

The tests cover signatures, changed raw bytes, stale and future timestamps,
missing/repeated headers, malformed JSON, envelope mismatch, storage failure,
concurrent duplicate requests, restart persistence, and waiting for acceptance
before acknowledging. The fictional Unicode/slash vector was cross-checked
against the PHP sender's JSON flags and HMAC calculation.

## Example files

<CodeGroup>
  ```javascript receiver.mjs theme={null}
  import { createHmac, timingSafeEqual } from 'node:crypto';
  import { mkdir, open, readFile, rename, rm } from 'node:fs/promises';
  import { createServer } from 'node:http';
  import { dirname, join, resolve } from 'node:path';
  import { pathToFileURL } from 'node:url';

  const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
  const eventType = /^[a-z][a-z0-9]*(?:[._][a-z0-9]+)*$/;
  const jsonType = /^application\/json(?:\s*;\s*charset=utf-8)?$/i;

  export class Rejected extends Error {
    constructor(status, message) { super(message); this.status = status; }
  }

  function header(request, name) {
    const values = [];
    for (let i = 0; i < request.rawHeaders.length; i += 2) {
      if (request.rawHeaders[i].toLowerCase() === name) values.push(request.rawHeaders[i + 1]);
    }
    if (values.length !== 1) throw new Rejected(400, 'Missing or repeated header');
    return values[0];
  }

  export function verify(request, rawBody, secret, now = Math.floor(Date.now() / 1000)) {
    const timestamp = header(request, 'x-thefaithapp-timestamp');
    const signature = header(request, 'x-thefaithapp-signature');
    const id = header(request, 'x-thefaithapp-event-id');
    const type = header(request, 'x-thefaithapp-event');
    if (!/^(0|[1-9][0-9]{0,12})$/.test(timestamp) || !/^v1=[0-9a-f]{64}$/.test(signature)
        || !uuid.test(id) || !eventType.test(type)) throw new Rejected(400, 'Malformed signing headers');
    // Five minutes is this example's policy, not a server-enforced contract.
    if (Math.abs(now - Number(timestamp)) > 300) throw new Rejected(401, 'Timestamp outside replay window');
    const expected = createHmac('sha256', secret).update(timestamp + '.').update(rawBody).digest();
    if (!timingSafeEqual(expected, Buffer.from(signature.slice(3), 'hex'))) {
      throw new Rejected(401, 'Invalid signature');
    }
    let payload;
    try { payload = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(rawBody)); }
    catch { throw new Rejected(400, 'Invalid UTF-8 JSON'); }
    if (!payload || Array.isArray(payload) || payload.id !== id || payload.type !== type
        || typeof payload.created_at !== 'string' || !Number.isFinite(Date.parse(payload.created_at))
        || !payload.data || typeof payload.data !== 'object') {
      throw new Rejected(400, 'Invalid event envelope');
    }
    return { id: id.toLowerCase(), type, rawBody: rawBody.toString('utf8') };
  }

  async function syncDirectory(directory) {
    const handle = await open(directory, 'r');
    try { await handle.sync(); } finally { await handle.close(); }
  }

  // One process per inbox. This queue also serializes overlapping HTTP retries.
  export async function createInbox(directory) {
    directory = resolve(directory);
    try { await mkdir(directory, { mode: 0o700 }); }
    catch (error) { if (error.code !== 'EEXIST') throw error; }
    await syncDirectory(dirname(directory));
    let queue = Promise.resolve();
    return {
      accept(event) {
        const result = queue.then(async () => {
          const target = join(directory, event.id + '.json');
          let previous, exists = false;
          try { previous = JSON.parse(await readFile(target, 'utf8')); exists = true; }
          catch (error) { if (error.code !== 'ENOENT') throw error; }
          if (exists) {
            if (!previous || previous.id !== event.id || previous.type !== event.type || previous.rawBody !== event.rawBody) {
              throw new Rejected(409, 'Event ID already has different content');
            }
            // Also sync retries: the earlier attempt may have failed after rename.
            const existing = await open(target, 'r');
            try { await existing.sync(); } finally { await existing.close(); }
            await syncDirectory(directory);
            return false;
          }
          const temporary = join(directory, '.' + event.id + '.' + process.pid + '.tmp');
          // A stale temporary file from a terminated process is never an accepted event.
          await rm(temporary, { force: true });
          const file = await open(temporary, 'wx', 0o600);
          try {
            await file.writeFile(JSON.stringify(event) + '\n');
            await file.sync();
          } finally { await file.close(); }
          await rename(temporary, target);
          await syncDirectory(directory);
          return true;
        });
        queue = result.catch(() => {}); // One failed write must not block later retries.
        return result;
      },
    };
  }

  function readBody(request, limit) {
    return new Promise((resolveBody, reject) => {
      const chunks = [];
      let size = 0;
      let oversized = false;
      request.on('data', chunk => {
        size += chunk.length;
        if (size > limit) {
          if (!oversized) reject(new Rejected(413, 'Body too large'));
          oversized = true;
          chunks.length = 0;
        } else if (!oversized) chunks.push(chunk);
      });
      request.on('end', () => { if (!oversized) resolveBody(Buffer.concat(chunks)); });
      request.on('error', reject);
      request.on('aborted', () => reject(new Rejected(400, 'Incomplete request')));
    });
  }

  export function createReceiver({ secret, inbox, now, bodyLimit = 1024 * 1024 }) {
    if (!secret) throw new Error('A signing secret is required');
    const server = createServer(async (request, response) => {
      let status, result;
      try {
        if (request.url !== '/webhooks/thefaithapp') throw new Rejected(404, 'Not found');
        if (request.method !== 'POST') {
          response.setHeader('Allow', 'POST');
          throw new Rejected(405, 'Use POST');
        }
        if (!jsonType.test(header(request, 'content-type'))) throw new Rejected(415, 'Use application/json');
        const event = verify(request, await readBody(request, bodyLimit), secret, now?.());
        const inserted = await inbox.accept(event);
        status = inserted ? 202 : 200;
        result = { accepted: true, duplicate: !inserted };
      } catch (error) {
        status = error instanceof Rejected ? error.status : 503;
        result = { error: error instanceof Rejected ? error.message : 'Inbox unavailable; retry later' };
      }
      // No request bodies, signing secrets, or internal filesystem errors are logged.
      response.writeHead(status, { 'Content-Type': 'application/json', 'Connection': 'close' });
      response.end(JSON.stringify(result));
    });
    server.requestTimeout = 10_000;
    server.headersTimeout = 10_000;
    return server;
  }

  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
    try {
      const inbox = await createInbox(resolve('inbox'));
      const server = createReceiver({ secret: process.env.TFA_WEBHOOK_SECRET, inbox });
      server.listen(8787, '127.0.0.1', () => console.log('Local receiver: http://127.0.0.1:8787/webhooks/thefaithapp'));
      server.on('error', () => { console.error('Could not start local receiver'); process.exitCode = 1; });
    } catch {
      console.error('Could not initialize receiver; check TFA_WEBHOOK_SECRET and local inbox storage');
      process.exitCode = 1;
    }
  }
  ```

  ```javascript fixture.mjs theme={null}
  import { createHmac } from 'node:crypto';
  import { resolve } from 'node:path';
  import { pathToFileURL } from 'node:url';

  // Fictional data only. The raw string deliberately contains Unicode and a slash.
  export const fixtureBody = '{"id":"02fdcf7e-822d-4ce8-b35e-cb9f28ad56d2","type":"platform.test","created_at":"2026-10-16T09:00:00.000000Z","data":{"message":"Fictional café / test"}}';
  export const fixtureSecret = 'whsec_fictional_example_only_never_use_in_production';
  export const fixtureTimestamp = '1792141200';

  export function signedHeaders(body, secret, timestamp = String(Math.floor(Date.now() / 1000))) {
    const { id, type } = JSON.parse(body);
    return {
      'Content-Type': 'application/json',
      'X-TheFaithApp-Event': type,
      'X-TheFaithApp-Event-Id': id,
      'X-TheFaithApp-Timestamp': timestamp,
      'X-TheFaithApp-Signature': 'v1=' + createHmac('sha256', secret).update(timestamp + '.').update(body).digest('hex'),
    };
  }

  if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
    if (!process.env.TFA_WEBHOOK_SECRET) throw new Error('Set the same local TFA_WEBHOOK_SECRET as the receiver');
    // Fixed loopback destination: this helper cannot send to a production endpoint.
    const response = await fetch('http://127.0.0.1:8787/webhooks/thefaithapp', {
      method: 'POST', headers: signedHeaders(fixtureBody, process.env.TFA_WEBHOOK_SECRET), body: fixtureBody,
    });
    console.log(response.status, await response.text());
    if (!response.ok) process.exitCode = 1;
  }
  ```
</CodeGroup>

<Accordion title="receiver.test.mjs — copy this file to run the tests">
  ```javascript receiver.test.mjs theme={null}
  import assert from 'node:assert/strict';
  import { createHmac } from 'node:crypto';
  import { request } from 'node:http';
  import { mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises';
  import { tmpdir } from 'node:os';
  import { join } from 'node:path';
  import test from 'node:test';
  import { createInbox, createReceiver, verify } from './receiver.mjs';
  import { fixtureBody, fixtureSecret, fixtureTimestamp, signedHeaders } from './fixture.mjs';

  const now = Number(fixtureTimestamp);
  const fixtureId = JSON.parse(fixtureBody).id;
  const headers = () => signedHeaders(fixtureBody, fixtureSecret, fixtureTimestamp);
  const rawRequest = values => ({ rawHeaders: Object.entries(values).flat() });
  const signedAnyBody = body => ({ ...headers(), 'X-TheFaithApp-Signature': 'v1=' +
    createHmac('sha256', fixtureSecret).update(fixtureTimestamp + '.').update(body).digest('hex') });
  const verifyBody = (body = fixtureBody, values = headers(), time = now) =>
    verify(rawRequest(values), Buffer.from(body), fixtureSecret, time);

  async function harness(t, options = {}) {
    const root = await mkdtemp(join(tmpdir(), 'tfa-webhook-example-'));
    const directory = join(root, 'inbox');
    const inbox = await createInbox(directory);
    const server = createReceiver({ secret: fixtureSecret, inbox, now: () => now, ...options });
    await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
    t.after(async () => {
      server.closeAllConnections();
      await new Promise(resolve => server.close(resolve));
      await rm(root, { recursive: true, force: true });
    });
    async function send(body = fixtureBody, values = headers(), { method = 'POST', path = '/webhooks/thefaithapp' } = {}) {
      return new Promise((resolve, reject) => {
        const wireHeaders = Array.isArray(values) ? [...values, 'Host', '127.0.0.1', 'Content-Length', String(Buffer.byteLength(body))] : values;
        const req = request({ hostname: '127.0.0.1', port: server.address().port, path, method, headers: wireHeaders }, response => {
          let text = '';
          response.setEncoding('utf8').on('data', chunk => { text += chunk; });
          response.on('end', () => resolve({ status: response.statusCode, body: JSON.parse(text) }));
        });
        req.on('error', reject);
        req.end(body);
      });
    }
    return { directory, inbox, send };
  }

  test('fixed PHP-compatible Unicode/slash signing vector and exact bytes', () => {
    assert.equal(headers()['X-TheFaithApp-Signature'], 'v1=dd29fa30b814fa96065cefd2b31c27bf4a643d770a609416e4ee905d5368a8a9');
    assert.equal(verifyBody().rawBody, fixtureBody);
    assert.throws(() => verifyBody(fixtureBody + '\n'), /Invalid signature/);
    assert.throws(() => verifyBody(fixtureBody.replace('café', 'cafe')), /Invalid signature/);
  });

  test('missing, repeated and malformed signing headers are rejected', () => {
    for (const name of ['X-TheFaithApp-Event', 'X-TheFaithApp-Event-Id', 'X-TheFaithApp-Timestamp', 'X-TheFaithApp-Signature']) {
      const missing = headers(); delete missing[name];
      assert.throws(() => verifyBody(fixtureBody, missing), /Missing or repeated/);
      const repeated = rawRequest(headers()); repeated.rawHeaders.push(name.toLowerCase(), headers()[name]);
      assert.throws(() => verify(repeated, Buffer.from(fixtureBody), fixtureSecret, now), /Missing or repeated/);
    }
    for (const [name, value] of [
      ['X-TheFaithApp-Signature', 'v1=00'], ['X-TheFaithApp-Signature', 'v2=' + '0'.repeat(64)],
      ['X-TheFaithApp-Timestamp', '1e9'], ['X-TheFaithApp-Timestamp', '+' + fixtureTimestamp],
      ['X-TheFaithApp-Event-Id', '../escape'], ['X-TheFaithApp-Event', 'Event Updated'],
    ]) assert.throws(() => verifyBody(fixtureBody, { ...headers(), [name]: value }), /Malformed/);
  });

  test('five-minute replay policy rejects stale and future timestamps, including on duplicates', async t => {
    assert.equal(verifyBody(fixtureBody, headers(), now + 300).id, fixtureId);
    for (const time of [now + 301, now - 301]) assert.throws(() => verifyBody(fixtureBody, headers(), time), /replay window/);
    let clock = now;
    const { send } = await harness(t, { now: () => clock });
    assert.equal((await send()).status, 202);
    clock += 301;
    assert.equal((await send()).status, 401);
    assert.equal((await send(fixtureBody, signedHeaders(fixtureBody, fixtureSecret, String(clock)))).status, 200);
  });

  test('verified JSON must have a matching envelope; no reserialization before HMAC', () => {
    for (const payload of [null, [], {}, { ...JSON.parse(fixtureBody), data: null },
      { ...JSON.parse(fixtureBody), created_at: 'invalid' }]) {
      const body = JSON.stringify(payload);
      assert.throws(() => verifyBody(body, signedAnyBody(body)), /Invalid event envelope/);
    }
    for (const body of [Buffer.from('{truncated'), Buffer.from([0xff])]) {
      assert.throws(() => verifyBody(body, signedAnyBody(body)), /Invalid UTF-8 JSON/);
    }
    const body = fixtureBody.replace(fixtureId, '12fdcf7e-822d-4ce8-b35e-cb9f28ad56d2');
    const values = signedHeaders(body, fixtureSecret, fixtureTimestamp);
    values['X-TheFaithApp-Event-Id'] = fixtureId;
    assert.throws(() => verifyBody(body, values), /Invalid event envelope/);
    const pretty = JSON.stringify(JSON.parse(fixtureBody), null, 2);
    assert.equal(verifyBody(pretty, signedHeaders(pretty, fixtureSecret, fixtureTimestamp)).rawBody, pretty);
  });

  test('accepted event is complete on disk; repeat and fresh-timestamp retry survive restart', async t => {
    const { directory, send } = await harness(t);
    assert.deepEqual(await send(), { status: 202, body: { accepted: true, duplicate: false } });
    assert.deepEqual(JSON.parse(await readFile(join(directory, fixtureId + '.json'), 'utf8')), verifyBody());
    assert.deepEqual(await send(), { status: 200, body: { accepted: true, duplicate: true } });
    // Constructing a new store has no in-memory seen-ID cache.
    assert.equal(await (await createInbox(directory)).accept(verifyBody()), false);
    assert.equal((await readdir(directory)).filter(name => name.endsWith('.json')).length, 1);
  });

  test('twenty simultaneous duplicate requests produce one accepted inbox record', async t => {
    const { directory, send } = await harness(t);
    const results = await Promise.all(Array.from({ length: 20 }, () => send()));
    assert.equal(results.filter(result => result.status === 202).length, 1);
    assert.equal(results.filter(result => result.status === 200).length, 19);
    assert.deepEqual(await readdir(directory), [fixtureId + '.json']);
  });

  test('same event ID with changed signed content is a conflict and cannot overwrite the first event', async t => {
    const { directory, send } = await harness(t);
    await send();
    const changed = fixtureBody.replace('Fictional', 'Changed');
    assert.equal((await send(changed, signedHeaders(changed, fixtureSecret, fixtureTimestamp))).status, 409);
    assert.equal(JSON.parse(await readFile(join(directory, fixtureId + '.json'), 'utf8')).rawBody, fixtureBody);
  });

  test('storage failure returns non-2xx; retry can recover without a poisoned queue', async t => {
    const { directory, send } = await harness(t);
    await rm(directory, { recursive: true });
    assert.deepEqual(await send(), { status: 503, body: { error: 'Inbox unavailable; retry later' } });
    await createInbox(directory);
    assert.equal((await send()).status, 202);
  });

  test('corrupt saved event fails closed; no overwrite or successful acknowledgement', async t => {
    const { directory, send } = await harness(t);
    const target = join(directory, fixtureId + '.json');
    await writeFile(target, '{truncated');
    assert.equal((await send()).status, 503);
    assert.equal(await readFile(target, 'utf8'), '{truncated');
  });

  test('HTTP path, method, content type, body limit and duplicate wire headers are checked', async t => {
    const { send } = await harness(t);
    assert.equal((await send('', headers(), { path: '/different' })).status, 404);
    assert.equal((await send('', headers(), { method: 'GET' })).status, 405);
    assert.equal((await send(fixtureBody, { ...headers(), 'Content-Type': 'text/plain' })).status, 415);
    assert.equal((await send(fixtureBody, [...Object.entries(headers()), ['x-thefaithapp-event-id', fixtureId]].flat())).status, 400);
    const limited = await harness(t, { bodyLimit: 8 });
    assert.equal((await limited.send()).status, 413);
  });

  test('no 2xx before the inbox finishes durable acceptance', async t => {
    let release;
    const persisted = new Promise(resolve => { release = resolve; });
    let reached;
    const started = new Promise(resolve => { reached = resolve; });
    const { send } = await harness(t, { inbox: { async accept() { reached(); await persisted; return true; } } });
    let returned = false;
    const response = send().then(value => { returned = true; return value; });
    await started;
    assert.equal(returned, false);
    release();
    assert.equal((await response).status, 202);
  });
  ```
</Accordion>

## Before adapting this for production

Run **one process per inbox**. This sample has no distributed lock, business
worker or database transaction. It accepts events into storage; it does not
apply business changes or guarantee exactly-once business processing. For a
production service, use a transactional inbox/queue and an idempotent worker,
HTTPS ingress, a secret manager, monitoring and a retention policy.

Keep accepted records throughout your retry horizon: deleting the inbox also
deletes deduplication history. Test persistence on your actual storage. If file
or directory sync fails, the example fails rather than acknowledging unsynced
data. Use only fictional data locally; the included fixed test secret is not
a production credential. The example logs neither request bodies nor secrets.

See [Node's file sync](https://nodejs.org/api/fs.html#filehandlesync) and
[raw HTTP headers](https://nodejs.org/api/http.html#messagerawheaders)
references for the underlying Node APIs.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.