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.
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.
docker compose up --buildThe 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.
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)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_jobsrows with astate(queued/running/done/dead) andrunAtare claimed by an in-process worker loop via a lease (anUPDATE ... 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.
apps/api/ # Hono API server + worker
apps/web/ # React UI
packages/shared/ # Zod schemas / shared types
specs/ # product spec (source of truth)
| 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.
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.shOr do it by hand with curl:
-
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"}'
-
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.
-
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"
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 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 toretryMaxAttempts; 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.
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 tohttp://localhost:<port>/...if your target also runs outside Docker on the same network namespace.
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 |
npm run typecheck— typecheck all workspacesnpm test— run all workspace test suitesnpm run build— build all workspacesnpm run lint— lint the reposcripts/demo.ps1/scripts/demo.sh— end-to-end demo against a running stack (create endpoint, send a webhook, print the inbox URL)
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.


