Skip to content
~sami-ullah

Concept project

Webhook Relay and Event Logger

A concept project with a working demo: a webhook relay that verifies HMAC-SHA256 signatures on the raw body, stores every request, drops duplicate event ids and fans each event out to several destinations with scheduled retries and manual replay. The demo runs in the browser; the case study explains the Node.js and PostgreSQL design.

Screenshot of the Webhook Relay and Event Logger demo

Overview#

Payment, booking and shipping providers talk to your system through webhooks. If you miss one, an order stays unpaid in your database while the customer has been charged. If you process one twice, you ship twice.

A relay sits between the providers and your internal services. It checks that each request really came from the provider, writes it down before doing anything else, and forwards it to every service that cares, retrying when a service is down. I built this concept to show that integration work end to end. The live demo computes real HMAC signatures in your browser.

My role#

Everything: the verification code, the storage and delivery rules, the simulated delivery worker, the demo UI and this write-up. It is a concept project, not client work. The providers and destinations in the demo are fictional.

The problem#

Webhooks fail in a few predictable ways:

  • Forged requests. The inbound URL is public. Anyone can POST a fake payment.succeeded to it.
  • Replays. An attacker who captured one valid request can send it again later.
  • Duplicates. Providers deliver "at least once". If your 200 arrives late, they send the same event again.
  • Slow or dead consumers. If the CRM is down, the provider should still get a fast 2xx, and the CRM should get the event when it comes back.
  • No paper trail. When something goes wrong, someone needs to see exactly what arrived and what was sent where.

Approach & architecture#

◆ Receive
  1. Providerthen
  2. POST /in/:provider (raw body)then
  3. verify HMACthen
  4. INSERT eventsthen
  5. 202
  1. verify HMACthen
  2. mismatchthen
  3. INSERT events (verified = false)then
  4. 401
  1. INSERT eventsthen
  2. unique (provider, provider_event_id)then
  3. 200 duplicate
◆ Deliver
  1. INSERT eventsthen
  2. INSERT deliveries (one per destination)then
  3. worker (SKIP LOCKED)then
  4. POST destination
  1. POST destinationthen
  2. 5xx or timeoutthen
  3. retry at 1 min, 5 min, 30 min, 2 hthen
  4. failedthen
  5. replay

Verify the raw bytes. The signature covers the exact bytes the provider sent. If a JSON parser runs first and you re-serialise, whitespace or key order can change and a valid request fails. So the receiver reads the raw body, verifies, and only then parses. The demo's verify function is the same code a Node.js handler would run, because Node 20 ships the same Web Crypto API:

ts
async function hmac(secret: string, message: string): Promise<Uint8Array> {
  const key = await crypto.subtle.importKey(
    "raw", enc.encode(secret), { name: "HMAC", hash: "SHA-256" }, false, ["sign"],
  );
  return new Uint8Array(await crypto.subtle.sign("HMAC", key, enc.encode(message)));
}

The compare is constant-time, so response timing does not reveal how much of a guessed signature was right. For providers that sign timestamp.body, a request more than five minutes old is refused even when the signature matches. That closes the replay hole.

Store before answering. Every request becomes a row, including rejected ones, so there is an audit trail of attacks and misconfigured secrets. Duplicates are caught by a unique index, not by a lookup in application code:

sql
CREATE TABLE events (
  id                 text PRIMARY KEY,
  provider           text NOT NULL,
  provider_event_id  text,
  type               text NOT NULL,
  headers            jsonb NOT NULL,
  body               text NOT NULL,          -- raw, exactly as signed
  verified           boolean NOT NULL,
  received_at        timestamptz NOT NULL DEFAULT now()
);
CREATE UNIQUE INDEX events_dedupe ON events (provider, provider_event_id) WHERE verified;

CREATE TABLE deliveries (
  id               bigserial PRIMARY KEY,
  event_id         text NOT NULL REFERENCES events(id),
  destination_id   text NOT NULL,
  status           text NOT NULL DEFAULT 'pending',
  attempts         int NOT NULL DEFAULT 0,
  next_attempt_at  timestamptz,
  replay           boolean NOT NULL DEFAULT false
);
CREATE INDEX deliveries_due ON deliveries (next_attempt_at) WHERE status = 'pending';

The partial unique index only covers verified rows. A forged request with a real event id cannot block the real one that arrives later.

Deliver from a worker. In the production design the worker claims due rows with SELECT … FOR UPDATE SKIP LOCKED LIMIT 20, so two workers never send the same delivery. Each destination has its own delivery row, so one dead consumer does not hold up the others.

Key challenges#

  • Testing failure without waiting hours. The retry schedule runs up to two hours. The demo uses a simulated clock where one real second is one minute, plus a "skip to next retry" button that jumps the clock to the next due delivery.
  • Showing the crypto honestly. It would be easy to fake verification with a flag. Instead the demo signs on the "provider" side and verifies on the "relay" side with real HMACs. The raw request console lets you edit one character of the body and watch the expected and received signatures diverge.
  • Replay without losing history. Replay inserts new delivery rows marked replay rather than resetting old ones. The attempt history of the original failure stays intact.
  • Masking. The UI masks provider secrets and any Authorization-style header, so the event log can be shown on a screen share.

Tech stack#

  • Demo: TypeScript, React and Next.js, with the Web Crypto API for HMAC-SHA256. The relay logic is a framework-free module (relay.ts). State is kept in localStorage.
  • Production design: a Node.js receiver on the built-in http module (raw body, no body parser in front), a delivery worker process, PostgreSQL for events and deliveries, and a small React log viewer.

Results#

  • A working relay demo with three signature schemes (timestamped hex, sha256= hex and base64), tamper, wrong-secret and stale-timestamp checks, dedupe by provider event id, fan-out by event type, a five-attempt retry schedule, per-destination and full replay, and a filterable event log.
  • Signature verification is real: the browser computes the same HMAC-SHA256 values Node.js would.
  • The code under "View source" on the demo page is the code that runs.

There are no delivery volumes or latency numbers here because this is a concept build without real users or real providers.

Try the demo#

Open the webhook relay demo. Things to try:

  1. Tick tamper with the body after signing and send. The response is 401 and the event is stored with verified = no.
  2. Send a Paylane event, then tick resend the previous event and send again. The second answers 200 duplicate and nothing is forwarded.
  3. Set crm-sync to down, send a booking, and press skip to next retry a few times. Then set it back to healthy and press replay to crm-sync.
  4. Open edit raw request, change one digit in the body, and send it. Then press re-sign with correct secret and send again.

What I would do next#

  • Build the Node.js receiver and worker against PostgreSQL and add a concurrency test with two workers on the same table.
  • Support secret rotation, with two active secrets per endpoint during the switch.
  • Add per-destination circuit breaking, so a destination that has been down for an hour is paused instead of retried for every event.
Full screenshot of the Webhook Relay and Event Logger demo running in the browser
Screenshot of the live demo with its sample data. Click to open it.
▶ live demoFire signed webhooks, tamper with one, resend a duplicate and watch a flaky destination retry on a schedule.

Skills used

#nestjs6 min

NestJS JWT Refresh Tokens: Rotation and Cookies

How to build NestJS JWT refresh tokens properly: short-lived access tokens, rotated refresh tokens stored as hashes, httpOnly cookies and reuse detection.

read →

#nextjs6 min

JSON-LD Structured Data in Next.js 15

How to add JSON-LD structured data in Next.js 15: a safe script component, typed schema-dts graphs with stable @id values, FAQ rules and how to test it.

read →