A production-ready webhook inspection dashboard built on Cloudflare Workers, D1, React, and TailwindCSS v4.
Inspired by Stripe Logs, Vercel Logs, and GitHub webhook inspection tools.
| Layer | Technology |
|---|---|
| Runtime | Cloudflare Workers |
| Database | Cloudflare D1 (SQLite) |
| Frontend | React 19 + Vite |
| Styling | TailwindCSS v4 |
| Routing | React Router v7 |
| Deployment | Wrangler |
- Webhook receiver —
POST /api/:app-name/webhookaccepts JSON payloads, reads event type fromx-event-typeheader or request body - Events dashboard — sticky table with ID, event type, and timestamp columns
- Accordion rows — click any row to expand payload JSON and request headers inline
- Auto-refresh — configurable polling at 20s / 30s / 60s without full page reload
- Manual refresh — refresh button with loading indicator
- Copy JSON — one-click copy for both payload and headers
- Row count + last updated — visible in the dashboard toolbar
- App management — create and look up apps by slug from the home page
- Auto-pruning — keeps only the 100 most recent events per app
webhook/
├── migrations/
│ └── 0001_initial.sql # D1 schema: webhook_apps + webhook_events
├── worker/
│ ├── index.ts # Worker entry point + URL router
│ ├── db.ts # D1 database operations
│ └── handlers/
│ ├── apps.ts # GET /api/apps/:slug, POST /api/apps
│ ├── events.ts # GET /api/:slug/events
│ └── webhook.ts # POST /api/:slug/webhook
├── src/
│ ├── main.tsx # React entry + BrowserRouter
│ ├── App.tsx # Client-side routes
│ ├── index.css # Tailwind v4 + accordion styles
│ ├── pages/
│ │ ├── Home.tsx # App name lookup / create page
│ │ └── Dashboard.tsx # Events dashboard + toolbar
│ └── components/
│ ├── EventsTable.tsx # Table with accordion row expansion
│ └── JsonViewer.tsx # Syntax-highlighted JSON + copy button
├── wrangler.jsonc
├── INSTRUCTIONS.md # Full feature spec + design decisions
└── package.json
yarn installnpx wrangler d1 create webhook_dbCopy the database_id from the output and update wrangler.jsonc:
npx wrangler d1 migrations apply webhook_db --localyarn dev# Apply migrations on remote D1
npx wrangler d1 migrations apply webhook_db
# Build and deploy
yarn deployGET /api/apps/:slug
Returns 200 with app data, or 404 if not found.
POST /api/apps
Content-Type: application/json
{ "name": "my-app" }
Returns 201 with the created app. Returns 200 if the slug already exists.
POST /api/:slug/webhook
Content-Type: application/json
X-Event-Type: payment.succeeded (optional)
{ ...your payload... }
Stores the event and returns { "success": true }.
GET /api/:slug/events
Returns the latest 100 events ordered by created_at DESC.
POST /api/:slug/webhook
Content-Type: application/json
X-Event-Type: payment.succeeded (optional)
{ ...your payload... }
Stores the event and returns { "success": true }.
Basic event with header-based event type:
curl -X POST https://webhook.msar.dev/api/bill-app/webhook \
-H "Content-Type: application/json" \
-H "X-Event-Type: payment.succeeded" \
-d '{
"id": "evt_001",
"amount": 4900,
"currency": "usd",
"customer": "cus_abc123",
"status": "succeeded"
}'Event type from body field:
curl -X POST https://webhook.msar.dev/api/bill-app/webhook \
-H "Content-Type: application/json" \
-d '{
"event_type": "invoice.created",
"invoice_id": "inv_20260506",
"due_date": "2026-06-06",
"amount_due": 12000,
"customer_email": "user@example.com"
}'Bill paid event:
curl -X POST https://webhook.msar.dev/api/bill-app/webhook \
-H "Content-Type: application/json" \
-H "X-Event-Type: bill.paid" \
-d '{
"bill_id": "bill_789",
"paid_at": "2026-05-06T14:40:00Z",
"amount": 25000,
"method": "bank_transfer",
"reference": "TXN-2026-88812"
}'Send multiple events quickly (bash loop):
for event in "bill.created" "bill.due" "bill.paid" "bill.overdue"; do
curl -s -X POST https://webhook.msar.dev/api/bill-app/webhook \
-H "Content-Type: application/json" \
-H "X-Event-Type: $event" \
-d "{\"event\": \"$event\", \"timestamp\": \"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
&& echo " ✓ $event"
done- Payloads are capped at 1 MB
AuthorizationandCookieheaders are stripped before storage- All JSON is HTML-escaped before rendering (no XSS)
- Uses
crypto.randomUUID()— noMath.random()
- Run
yarn cf-typegenafter changing bindings inwrangler.jsoncto regenerateworker-configuration.d.ts - The React SPA is served via the
ASSETSbinding; all/api/*routes are handled by the Worker - Tailwind v4 requires no config file — configured entirely via
@tailwindcss/viteplugin