This site is not affiliated with or endorsed by Cloudflare, Inc. It showcases deployable experiments across most Cloudflare products.
Stateful & Async

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_FOUND
  • 400 - 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.

DependenciesNoneBindingsDurable ObjectsPlatformDurable Objects
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

Click the deploy button

Deploy to Cloudflare Workers

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 dev

Configuration

Durable Object RELAY / WebhookRelay with migration in wrangler.json.

Cloudflare Features Used

On this page