Skip to documentation content
HintadataDocs

TypeScript webhook receiver

This single-subscription receiver verifies exact raw bytes with the SDK, rejects invalid or expired signatures, and atomically records the event ID and one local alert row in SQLite. It acknowledges duplicates without another alert. webhook.test is verified and recorded but never creates an alert. Unknown event types are acknowledged without business effects. The example does not send email or mutate an external system.

In this guide
  1. 1Build a durable local inbox
  2. 2Install and configure
  3. 3Save the receiver
  4. 4Verify before exposing it
  5. 5Place behind your HTTPS service
  6. 6Extend without losing duplicate protection
  7. 7Accept two keys only during rotation
In this guide
  1. 1Build a durable local inbox
  2. 2Install and configure
  3. 3Save the receiver
  4. 4Verify before exposing it
  5. 5Place behind your HTTPS service
  6. 6Extend without losing duplicate protection
  7. 7Accept two keys only during rotation

Build a durable local inbox

This single-subscription receiver verifies exact raw bytes with the SDK, rejects invalid or expired signatures, and atomically records the event ID and one local alert row in SQLite. It acknowledges duplicates without another alert. webhook.test is verified and recorded but never creates an alert. Unknown event types are acknowledged without business effects. The example does not send email or mutate an external system.

Install and configure

Use a separate empty directory. Node.js 22.13+ includes node:sqlite; Python 3.11+ includes sqlite3. Set WEBHOOK_SECRET privately to the signing secret returned at subscription creation, not your API key. WEBHOOK_DB must name a persistent writable local file. One database and secret set belong to one subscription; do not mix customer tenants. For local verification, use a disposable database and a local-only secret of at least 32 characters.

bashillustrative
Example trust: illustrativeShows the shape or idea; do not use it unchanged in production.
npm init -y
npm install https://hintadata.fi/sdk/price-compare-typescript-3.1.0.tgz
npm install --save-dev typescript tsx @types/node

Save the receiver

Save as receiver.ts. The listener binds only to loopback on port 8080.

typescriptillustrative
Example trust: illustrativeShows the shape or idea; do not use it unchanged in production.
import { createServer } from 'node:http';
import { DatabaseSync } from 'node:sqlite';
import { verifyWebhook } from '@pricecompare/sdk';

const secrets = [process.env.WEBHOOK_SECRET, process.env.WEBHOOK_PREVIOUS_SECRET].filter((value): value is string => Boolean(value));
if (!secrets.length || secrets.some(secret => secret.length < 32)) throw new Error('Configure the webhook signing secret.');
const db = new DatabaseSync(process.env.WEBHOOK_DB || 'receiver.sqlite');
db.exec('PRAGMA busy_timeout=5000; CREATE TABLE IF NOT EXISTS received (id TEXT PRIMARY KEY); CREATE TABLE IF NOT EXISTS alerts (event_id TEXT PRIMARY KEY, product_id TEXT NOT NULL);');

const server = createServer(async (request, response) => {
  const reply = (status: number) => { response.writeHead(status); response.end(); };
  if (request.method !== 'POST' || request.url !== '/events') { reply(404); return; }
  const signature = request.headers['x-webhook-signature'];
  if (typeof signature !== 'string') { reply(400); return; }
  let raw: Buffer;
  try {
    const chunks: Buffer[] = []; let size = 0;
    for await (const chunk of request) {
      size += chunk.length;
      if (size > 262144) { reply(413); return; }
      chunks.push(chunk);
    }
    raw = Buffer.concat(chunks);
  } catch { reply(400); return; }
  let event: Awaited<ReturnType<typeof verifyWebhook>> | undefined;
  for (const secret of secrets) {
    try { event = await verifyWebhook(raw, signature, secret); break; } catch { /* Try the explicitly configured overlap key. */ }
  }
  if (!event) { reply(400); return; }
  if (!['watchlist.triggered', 'webhook.test'].includes(event.type)) { reply(204); return; }
  if (event.type === 'watchlist.triggered' && (typeof event.data.canonical_product_id !== 'string' || !event.data.canonical_product_id)) { reply(400); return; }
  try {
    db.exec('BEGIN IMMEDIATE');
    const inserted = db.prepare('INSERT OR IGNORE INTO received (id) VALUES (?)').run(event.id);
    if (inserted.changes && event.type === 'watchlist.triggered') {
      // This local inbox row IS the effect. No external call inside the transaction.
      db.prepare('INSERT INTO alerts (event_id, product_id) VALUES (?, ?)').run(event.id, event.data.canonical_product_id as string);
    }
    db.exec('COMMIT');
  } catch {
    try { db.exec('ROLLBACK'); } catch { /* BEGIN may have failed. */ }
    reply(503); return; // No success acknowledgement before durable commit.
  }
  reply(204); // Duplicates and synthetic tests are acknowledged without another alert.
});
server.requestTimeout = 10000;
server.headersTimeout = 10000;
server.listen(Number(process.env.PORT || 8080), '127.0.0.1');
process.once('SIGTERM', () => server.close(() => { db.close(); }));
process.once('SIGINT', () => server.close(() => { db.close(); }));
bashillustrative
Example trust: illustrativeShows the shape or idea; do not use it unchanged in production.
npx tsx receiver.ts

Verify before exposing it

Using a local-only signing secret, send a signed watchlist.triggered event with a canonical_product_id. Expect 204 and one row in both received and alerts. Send the exact event again, then restart the receiver with the same database and send it again: both replies must be 204 and both row counts must remain one. Change one raw body byte without signing again: expect 400 and unchanged counts. Sign with a timestamp older than five minutes: expect 400. A signed webhook.test must return 204 without an alert. If durable storage fails, expect 503, not 204. These checks are also run against this exact published code in the repository.

Place behind your HTTPS service

The local listener is not a public production server. Put it behind a managed HTTPS reverse proxy on port 443 that preserves raw bytes, enforces a 256 KiB body limit and request deadlines, and forwards Content-Length (the Python example does not accept chunked transfer). Run one process for this example with persistent storage, restricted file permissions, backup and supervised restart. Register the public /events URL only after deploying it; localhost and private URLs cannot be registered. Never expose the database or log secrets and request bodies.

Extend without losing duplicate protection

The alert row is deliberately the whole business effect. For email or another external action, write a durable outbox entry in the same transaction and let a worker deliver it with an idempotency key. Do not mark an event received and then make an unprotected external call. Events may arrive out of order: this example appends notifications, it does not overwrite current watch state. Retain received IDs for the entire replay horizon, at least seven days; the example does not delete them automatically.

Accept two keys only during rotation

During the documented prepare/activate rotation, configure one secret as WEBHOOK_SECRET and the other as WEBHOOK_PREVIOUS_SECRET, then restart. Both are accepted, allowing the new key to be installed before activation. After activation and at least 60 seconds of overlap, keep only the new key as WEBHOOK_SECRET and restart. Never retain the old key indefinitely. A queued test is not delivery proof: verify delivered status and the received row.

Was this page helpful?