Every developer integrating webhooks goes through the same ritual: stand up a temporary ngrok tunnel, send a test event from the provider dashboard, scroll through logs, try to correlate what hit your server with what the provider claims it sent, repeat until something works. It's tedious, fragile, and throws away all context the moment you close the terminal.
A webhook playground eliminates that ritual. It gives developers a persistent, inspectable surface for sending, receiving, and replaying webhook events without involving production systems. This post covers how to design and build one — whether you're adding it to an existing platform or building it as a standalone developer tool.
What a Playground Actually Needs
Before writing any code, be precise about what problems you're solving. A useful webhook playground has four capabilities:
- ›Receive — accept an HTTP POST from any external system and log the full request (headers, body, timing)
- ›Inspect — show developers the exact payload, headers, and metadata for each received event
- ›Send — let developers fire a test event toward any target URL and see the response
- ›Replay — resend a previously captured event, to the same or a different target
Most "webhook testing" tools stop at receive and inspect. The send and replay capabilities are what make a playground worth integrating into a daily development workflow.
Step 1: Ephemeral Ingest Endpoints
Each playground session should have its own unique ingest URL — something like:
https://playground.yoursaas.com/in/abc123xyzThe path token abc123xyz is the session identifier. You don't need accounts or auth for playground endpoints; the token is a capability URL. Anyone with the URL can post to it.
type PlaygroundSession struct {
ID string `json:"id"`
IngestURL string `json:"ingest_url"`
CreatedAt time.Time `json:"created_at"`
ExpiresAt time.Time `json:"expires_at"`
}
func (h *PlaygroundHandler) Create(w http.ResponseWriter, r *http.Request) {
id := generateToken(16) // 16 random bytes, hex-encoded = 32 chars
session := PlaygroundSession{
ID: id,
IngestURL: fmt.Sprintf("https://playground.yoursaas.com/in/%s", id),
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(24 * time.Hour),
}
if err := h.store.CreateSession(r.Context(), session); err != nil {
httpx.InternalError(w, err)
return
}
httpx.Created(w, session)
}Set a TTL. Playground sessions that expire after 24 hours keep your database clean without requiring developers to explicitly close them. A background job deletes sessions and their captured events past the expiry time.
Step 2: Capturing Inbound Requests
When an external system posts to a playground ingest URL, you want to capture everything:
func (h *PlaygroundHandler) Ingest(w http.ResponseWriter, r *http.Request) {
sessionID := r.PathValue("session_id")
session, err := h.store.GetSession(r.Context(), sessionID)
if err != nil || session == nil {
http.Error(w, "not found", http.StatusNotFound)
return
}
if time.Now().UTC().After(session.ExpiresAt) {
http.Error(w, "session expired", http.StatusGone)
return
}
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1 MB cap
if err != nil {
http.Error(w, "read error", http.StatusBadRequest)
return
}
headers := make(map[string]string)
for key := range r.Header {
headers[key] = r.Header.Get(key)
}
capture := CapturedRequest{
ID: uuid.New().String(),
SessionID: sessionID,
Method: r.Method,
Headers: headers,
Body: string(body),
SourceIP: r.RemoteAddr,
ReceivedAt: time.Now().UTC(),
}
if err := h.store.SaveCapture(r.Context(), capture); err != nil {
httpx.InternalError(w, err)
return
}
// Notify any connected WebSocket clients for this session
h.hub.Broadcast(sessionID, capture)
w.WriteHeader(http.StatusOK)
}Two things worth noting here. First, always cap the body size. A provider sending a large batch payload, or a misconfigured client posting a file upload, can otherwise exhaust memory. 1 MB is generous for any webhook payload.
Second, broadcast the capture to connected WebSocket clients immediately. Developers watching the playground in a browser tab should see events appear in real time without polling.
Step 3: Real-Time Delivery via WebSocket
A playground without real-time updates feels broken. Developers expect to see events appear within milliseconds of sending them from another terminal or browser tab.
The simplest approach is a hub-and-spoke WebSocket model: the server maintains a map of session IDs to connected clients, and every captured request triggers a broadcast to all clients subscribed to that session.
type Hub struct {
mu sync.RWMutex
clients map[string][]*websocket.Conn // session_id -> connections
}
func (h *Hub) Subscribe(sessionID string, conn *websocket.Conn) {
h.mu.Lock()
defer h.mu.Unlock()
h.clients[sessionID] = append(h.clients[sessionID], conn)
}
func (h *Hub) Broadcast(sessionID string, event any) {
h.mu.RLock()
conns := h.clients[sessionID]
h.mu.RUnlock()
payload, _ := json.Marshal(event)
for _, conn := range conns {
conn.WriteMessage(websocket.TextMessage, payload)
}
}Clean up disconnected clients on write error. Don't let dead connections accumulate in the map.
Step 4: Sending Test Events Outbound
The flip side of receiving is sending. Developers need to fire a synthetic event toward a target URL and see the full response. This is the "test my endpoint" half of the workflow.
POST /playground/send
Content-Type: application/json
{
"target_url": "https://myapp.example.com/webhooks/stripe",
"method": "POST",
"headers": {
"Content-Type": "application/json",
"Stripe-Signature": "t=1711584000,v1=abc123..."
},
"body": "{\"type\": \"payment_intent.succeeded\", \"data\": {\"object\": {\"id\": \"pi_test\"}}}"
}The server makes the outbound HTTP request, captures the full response, and returns it to the developer:
{
"request": {
"url": "https://myapp.example.com/webhooks/stripe",
"method": "POST",
"headers": { "Content-Type": "application/json" },
"body": "{\"type\": \"payment_intent.succeeded\" ...}"
},
"response": {
"status": 200,
"headers": { "Content-Type": "application/json" },
"body": "{\"received\": true}",
"duration_ms": 43
},
"error": null
}Include duration_ms. Response latency is often the first thing developers want to know when debugging a slow webhook handler.
Enforce a timeout on outbound requests — 10 seconds is reasonable for a developer tool. Without it, a hanging target URL blocks your server goroutine indefinitely.
Step 5: Event Templates and Replay
Empty-payload testing is fine for connectivity checks. But the moment developers need to test business logic, they need realistic payloads. Provide a library of event templates for common providers:
| Provider | Sample Events |
|---|---|
| Stripe | payment_intent.succeeded, charge.failed, customer.subscription.deleted |
| GitHub | push, pull_request.opened, workflow_run.completed |
| Shopify | orders/create, products/update, fulfillments/create |
| Twilio | message.delivered, message.failed, call.completed |
| PagerDuty | incident.trigger, incident.resolve, incident.acknowledge |
Store templates as JSON files in your repo. Expose them via a GET /playground/templates endpoint. Let developers select a template, edit the payload in a text editor in the UI, and fire it.
Replay works the same way as send, but the body is pre-populated from a previously captured request:
POST /playground/replay
Content-Type: application/json
{
"capture_id": "cap_abc123",
"target_url": "https://myapp.example.com/webhooks/stripe"
}The server fetches the original capture, constructs a new outbound request using the captured headers and body, and sends it to the target. This is invaluable when a developer wants to reproduce a specific edge case that arrived in production without manually reconstructing the payload.
Step 6: Signature Verification Testing
Webhook security means nothing if developers can't easily test that their signature verification works correctly. Add a signing step to outbound sends:
POST /playground/send
Content-Type: application/json
{
"target_url": "https://myapp.example.com/webhooks",
"body": "{\"event\": \"test\"}",
"sign": {
"format": "stripe",
"secret": "whsec_test_abc123"
}
}The playground generates a valid HMAC signature in the provider's format and injects it as the appropriate header before sending. The developer can then verify their signature validation code catches both valid and tampered signatures.
For GetHook sources, the same HMAC signing logic already lives in the delivery layer. Wiring it into a playground sender is a matter of exposing the existing security.Sign() function with configurable format and secret parameters.
Schema: Keeping the Playground Lightweight
The playground data model doesn't need to be complex. Two tables handle everything:
CREATE TABLE playground_sessions (
id TEXT PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL
);
CREATE TABLE playground_captures (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id TEXT NOT NULL REFERENCES playground_sessions(id) ON DELETE CASCADE,
method TEXT NOT NULL,
headers JSONB NOT NULL DEFAULT '{}',
body TEXT NOT NULL DEFAULT '',
source_ip TEXT NOT NULL,
received_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX playground_captures_session ON playground_captures (session_id, received_at DESC);ON DELETE CASCADE on captures means your TTL cleanup job only needs to delete expired sessions — captures clean themselves up automatically. The index on (session_id, received_at DESC) covers the common query: fetch the most recent N captures for a session.
Operational Limits
A public playground endpoint is a write-heavy surface that can be abused. Set limits before you ship:
| Limit | Recommended Value |
|---|---|
| Max captures per session | 200 |
| Max body size per capture | 1 MB |
| Session TTL | 24 hours |
| Outbound send timeout | 10 seconds |
| Max outbound sends per session | 50 per hour |
| Max WebSocket connections per session | 5 |
These aren't arbitrary. Captures per session and body size limits bound your storage growth. Outbound send rate limits prevent the playground from being used as an HTTP proxying tool. WebSocket connection limits prevent a single session from holding too many open connections.
Why This Matters for Your Platform
A webhook playground directly reduces integration friction. Developers who can test against a real HTTP surface without spinning up infrastructure or configuring ngrok will reach a working integration faster. They'll also discover payload edge cases they wouldn't catch with mocked tests.
If you're building a platform where customers integrate via webhooks, the playground belongs in your dashboard from day one — not as an afterthought. The engineering investment is low relative to the support ticket volume it prevents.