Skip to content

Repository files navigation

Webhook Lab

Capture, verify, retry, and replay HTTP webhooks with a delivery inspector UI.

Webhook Lab — capture, verify, retry, and replay HTTP webhooks with a delivery inspector UI. TypeScript, SQLite, Docker.

Status

Phase 4 (portfolio polish) complete. All phases (0-4) are functionally done: scaffold, capture MVP, forward + retry, signatures + hardening, and this polish pass. See specs/webhook-lab.md for the full spec: requirements, data model, API, edge cases, phases, and acceptance criteria.

Quick start

docker compose up --build

The stack listens on http://localhost:8787 (API + UI, single service). Default admin token in compose is dev-admin-token.

docker compose up --build builds the same multi-stage Dockerfile used for production: a build stage compiles the web UI and installs deps, then a slim runtime stage serves the API and the built UI as static files from one process/port. Compose just supplies dev-friendly env vars (SSRF_PROTECT=false, a fixed ADMIN_TOKEN) and a named volume for the SQLite file.

Local dev (without Docker)

npm install
npm run dev:api   # API on http://localhost:8787
npm run dev:web   # UI on http://localhost:5173 (proxies /api, /in, /healthz to the API)

Architecture

Provider / curl
      |  POST /in/:endpointKey
      v
  Ingest API  --verify optional signature-->  Persist Event
      |
      +--> enqueue DeliveryJob (if forwarding enabled)
                |
                v
           Worker loop (polls every 1s)
                |
                +--> POST target URL
                +--> record DeliveryAttempt
                +--> retry / dead-letter
      |
      v
  Inspector UI / REST API (admin auth)
  • API (apps/api): a single Hono server on Node handles ingest (/in/:key), the admin API (/api/*), health checks, and (in the production image) serves the built UI as static files — one process, one port.
  • Persistence: SQLite via Drizzle ORM. Four tables: endpoints, events, delivery_jobs (the queue), delivery_attempts (the audit trail). Foreign keys cascade so deleting an endpoint removes its events, jobs, and attempts.
  • Queue: no Redis — delivery_jobs rows with a state (queued/running/done/dead) and runAt are claimed by an in-process worker loop via a lease (an UPDATE ... WHERE state = 'queued' that only one claimer can win), which keeps forwarding decoupled from the ingest request path.
  • UI (apps/web): a small React/Vite SPA with no client router — navigation is plain component state (endpoints list → endpoint detail → event detail → dead letters). It polls the admin API every 2s rather than using SSE/websockets, which is enough for a local inspector tool.
  • Shared types (packages/shared): Zod schemas are the single source of truth for both API validation and the types the UI imports, so a schema change can't silently drift between server and client.

Repository layout

apps/api/          # Hono API server + worker
apps/web/          # React UI
packages/shared/   # Zod schemas / shared types
specs/             # product spec (source of truth)

Screenshots

Endpoints list Event detail Dead letters
Endpoints list Event detail Dead letters

Live captures from a local run (create endpoint -> ingest -> forward failure -> dead letter). Re-capture these after any significant UI change.

Try it (curl demo)

Prefer a script? scripts/demo.ps1 (Windows/PowerShell) and scripts/demo.sh (bash) automate everything below: they check the stack is reachable, create an endpoint, POST a sample webhook, and print the inbox URL plus how to inspect the event. Run from the repo root after docker compose up --build (or npm run dev:api):

.\scripts\demo.ps1
./scripts/demo.sh

Or do it by hand with curl:

  1. Create an endpoint via the UI (http://localhost:8787, or :5173 in local dev), or via the API:

    curl -X POST http://localhost:8787/api/endpoints \
      -H "authorization: Bearer dev-admin-token" \
      -H "content-type: application/json" \
      -d '{"name": "My test endpoint", "key": "my-test"}'
  2. Send it a webhook:

    curl -X POST http://localhost:8787/in/my-test \
      -H "content-type: application/json" \
      -d '{"hello": "world"}'

    Ingest is intentionally unauthenticated, like a real provider's webhook URL.

  3. The event shows up in the endpoint's event list within 2 seconds (UI polls). Or fetch it directly:

    curl http://localhost:8787/api/endpoints/{endpointId}/events \
      -H "authorization: Bearer dev-admin-token"

Signature verification

Set an endpoint's signingProvider to github or stripe and give it a signingSecret to require valid signatures on ingest. Verification always runs over the raw request body bytes (not a re-serialized/re-parsed version), matching how GitHub and Stripe actually sign requests.

  • GitHub: X-Hub-Signature-256: sha256=<hex> = HMAC-SHA256(secret, rawBody)
  • Stripe: Stripe-Signature: t=<unix seconds>,v1=<hex> = HMAC-SHA256(secret, ${t}.${rawBody}), rejected if the timestamp is more than 300s old

An invalid or missing signature stores the event (status: rejected, visible in the UI for debugging) and returns 401 — chosen over 400 since the request is authenticated-but-unauthorized in intent, matching how these providers treat signature failures.

Forwarding, retries, and replay

  • Forwarding is async: ingest acks the provider immediately, then a DB-backed job queue and in-process worker loop (polls every second) deliver to the target.
  • Outbound delivery is at-least-once — a crash between a successful delivery and marking the job done can cause a duplicate send. Consumers should dedupe on X-Webhook-Lab-Event-Id, not assume exactly-once delivery.
  • Retry policy: 5xx/408/429 responses and network errors retry with exponential backoff (min(retryMaxDelayMs, retryBaseDelayMs * 2^(attempt-1)) plus up to 10% jitter) up to retryMaxAttempts; other 4xx responses dead-letter immediately without retrying, since retrying a client error (e.g. 400/404) won't ever succeed.
  • Manual replay (from the event or dead-letter view) re-enqueues delivery to the endpoint's forward URL, or an override URL you supply.

SSRF protection and local forwarding

Forward targets can point at internal infrastructure, so SSRF_PROTECT=true blocks saving or replaying to localhost/link-local/private-range targets (checked on save/update and on replay, including DNS resolution for hostnames). It defaults to true in the production Docker image and false in local docker compose / dev, so you can forward to services on your own machine.

To forward to an app running on your host machine from the Docker container:

  • Docker Desktop (Mac/Windows): use http://host.docker.internal:<port>/... as the forward URL.
  • Linux: add --add-host=host.docker.internal:host-gateway (already common in Compose) or use the host's LAN IP.
  • Alternatively, set SSRF_PROTECT=false (the local compose default) and forward directly to http://localhost:<port>/... if your target also runs outside Docker on the same network namespace.

Configuration (env)

See .env.example for the canonical, commented list.

Var Required Default Purpose
ADMIN_TOKEN yes in prod dev-admin-token in compose Protects admin API/UI mutations; ingest stays unauthenticated
DATABASE_URL no file:./data/webhook-lab.sqlite SQLite file path
PORT no 8787 API port
PUBLIC_ORIGIN no http://localhost:8787 Used to display inbox URLs in the UI
BODY_MAX_BYTES no 1048576 Global ceiling on ingest body size, in bytes
WORKER_ID no random Worker lease identity
LOG_LEVEL no info debug | info | warn | error
SSRF_PROTECT no true in the production image, false in local compose Blocks localhost/link-local/private-range forward targets

Scripts

  • npm run typecheck — typecheck all workspaces
  • npm test — run all workspace test suites
  • npm run build — build all workspaces
  • npm run lint — lint the repo
  • scripts/demo.ps1 / scripts/demo.sh — end-to-end demo against a running stack (create endpoint, send a webhook, print the inbox URL)

Development phases

This project is built in phases (see spec for full detail): scaffold, capture MVP, forward + retry, signatures + hardening, then portfolio polish (Phase 4 — this pass). Curl demo, architecture notes, and SSRF/local-forward guidance land as those phases complete.

License

MIT

About

Capture, verify, retry, and replay HTTP webhooks with a delivery inspector UI.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages