Webhook Relay Inspector
Capture inbound HTTP webhooks in a Durable Object session for debugging and replay - webhook inbox for local/dev testing
Create a unique inbound URL per session with a Durable Object. Any HTTP request to that URL is captured (method, headers, body, timestamp) and can be listed or inspected for webhook debugging.
Features
- POST /relay/new - create a capture session
- ALL /relay/:id - capture inbound requests
- GET /relay/:id/requests - list captures
- GET /relay/:id/requests/:requestId - full request details
API Reference
POST /relay/new
Create a new relay session.
Success Response
{ "id": "abc123", "inboundUrl": "https://your-worker.workers.dev/relay/abc123" }ALL /relay/:id
Captures any inbound HTTP request to the session.
GET /relay/:id/requests
List captured requests (summary).
GET /relay/:id/requests/:requestId
Full request details including headers and body.
Error Codes
404-NOT_FOUND400-INVALID_ID
Use Cases
- Debug Stripe, GitHub, or custom webhook payloads during integration
- Share a temporary inbound URL with a third party for testing
- Inspect headers and raw body without deploying a full receiver
Limitations
- Requires Durable Objects binding and migration
- Sessions are not authenticated; anyone with the URL can post
- Storage is bounded by DO limits; not long-term log retention
Use in your project
Copy these files into an existing Worker. Prefer Deployment to try the full experiment first. Source: apps/experiments/webhook-relay-inspector.
import { RELAY_ID_PATTERN } from "../constants/defaults";import type { CapturedRequest, CapturedRequestSummary } from "../types/relay";export function validateRelayId(id: string | undefined): string | null { if (!id || typeof id !== "string") return null; const trimmed = id.trim(); if (!RELAY_ID_PATTERN.test(trimmed)) return null; return trimmed;}export function buildInboundUrl(origin: string, id: string): string { return `${origin.replace(/\/$/, "")}/relay/${id}`;}export function headersToRecord(headers: Headers): Record<string, string> { const record: Record<string, string> = {}; headers.forEach((value, key) => { record[key] = value; }); return record;}export function queryToRecord(searchParams: URLSearchParams): Record<string, string> { const record: Record<string, string> = {}; searchParams.forEach((value, key) => { record[key] = value; }); return record;}export async function readRequestBody(request: Request, maxBytes: number): Promise<string | null> { if (request.method === "GET" || request.method === "HEAD") { return null; } const contentLength = Number(request.headers.get("content-length") ?? "0"); if (contentLength > maxBytes) { return `[body omitted: ${contentLength} bytes exceeds ${maxBytes} byte limit]`; } const buffer = await request.arrayBuffer(); if (buffer.byteLength > maxBytes) { return `[body omitted: ${buffer.byteLength} bytes exceeds ${maxBytes} byte limit]`; } if (buffer.byteLength === 0) { return null; } return new TextDecoder().decode(buffer);}export async function buildCapturedRequest( request: Request, maxBytes: number): Promise<CapturedRequest> { const url = new URL(request.url); const body = await readRequestBody(request, maxBytes); return { id: crypto.randomUUID(), timestamp: new Date().toISOString(), method: request.method, path: url.pathname, query: queryToRecord(url.searchParams), headers: headersToRecord(request.headers), body, };}export function summarizeRequest(record: CapturedRequest): CapturedRequestSummary { return { id: record.id, timestamp: record.timestamp, method: record.method, path: record.path, };}export function sortSummariesNewestFirst( summaries: CapturedRequestSummary[]): CapturedRequestSummary[] { return [...summaries].sort( (left, right) => new Date(right.timestamp).getTime() - new Date(left.timestamp).getTime() );}Deployment
Configure bindings
Durable Object RELAY / WebhookRelay with migration in wrangler.json.
Test your deployment
See the experiment README for curl examples.
Local Development
cd apps/experiments/webhook-relay-inspector
npm install
npm run devConfiguration
Durable Object RELAY / WebhookRelay with migration in wrangler.json.