How to Handle Webhooks in Go
Basic Webhook Handler
Go's standard library provides everything you need for a production-quality webhook handler. The net/http package handles routing and request parsing:
package mainimport ( "encoding/json" "fmt" "io" "log" "net/http" )
type WebhookEvent struct {
Type string json:"type"
Data json.RawMessage json:"data"
}
func webhookHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) return }
body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Failed to read body", http.StatusBadRequest) return } defer r.Body.Close()
var event WebhookEvent if err := json.Unmarshal(body, &event); err != nil { http.Error(w, "Invalid JSON", http.StatusBadRequest) return }
log.Printf("Webhook received: %s", event.Type)
switch event.Type { case "payment.completed": handlePayment(event.Data) case "user.created": handleUserCreated(event.Data) default: log.Printf("Unhandled event: %s", event.Type) }
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, {"received": true})
}
func main() { http.HandleFunc("/webhooks", webhookHandler) log.Println("Server listening on :8080") log.Fatal(http.ListenAndServe(":8080", nil)) } ```
Go reads the full body with io.ReadAll, giving you the raw bytes for both parsing and signature verification. The json.RawMessage type for Data defers parsing of the nested object until you know the event type.
Signature Verification
Go's crypto/hmac package provides HMAC verification:
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
)func verifySignature(payload []byte, signature, secret string) bool { mac := hmac.New(sha256.New, []byte(secret)) mac.Write(payload) expected := hex.EncodeToString(mac.Sum(nil))
return hmac.Equal([]byte(expected), []byte(signature)) }
func stripeWebhookHandler(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(r.Body) if err != nil { http.Error(w, "Bad request", http.StatusBadRequest) return }
sigHeader := r.Header.Get("Stripe-Signature") if sigHeader == "" { http.Error(w, "Missing signature", http.StatusUnauthorized) return }
// Parse Stripe signature format: t=timestamp,v1=hash parts := parseStripeSignature(sigHeader) signedPayload := parts["t"] + "." + string(body)
if !verifySignature([]byte(signedPayload), parts["v1"], os.Getenv("STRIPE_WEBHOOK_SECRET")) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return }
// Process verified event var event WebhookEvent json.Unmarshal(body, &event) processEvent(event)
w.WriteHeader(http.StatusOK) } ```
The hmac.Equal function performs constant-time comparison, preventing timing attacks. This is Go's equivalent of crypto.timingSafeEqual in Node.js.
Concurrent Processing with Goroutines
Go's goroutines make async webhook processing simple and efficient:
func webhookHandler(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)// Verify signature first...
var event WebhookEvent json.Unmarshal(body, &event)
// Idempotency check if isEventProcessed(event.ID) { w.WriteHeader(http.StatusOK) return } markEventProcessed(event.ID)
// Process asynchronously go func() { if err := processEvent(event); err != nil { log.Printf("Error processing event %s: %v", event.ID, err) } }()
// Respond immediately
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, {"received": true})
}
```
For production, use a proper work queue (like a channel-based worker pool) instead of unbounded goroutines to prevent resource exhaustion under high webhook volume:
var eventQueue = make(chan WebhookEvent, 1000)func init() { // Start worker pool for i := 0; i < 10; i++ { go func() { for event := range eventQueue { processEvent(event) } }() } }
func webhookHandler(w http.ResponseWriter, r *http.Request) { // ... parse and verify ... eventQueue <- event // Non-blocking send to worker pool w.WriteHeader(http.StatusOK) } ```
Using a Router Framework
For more complex webhook handling, use a router like Chi or Gin:
import "github.com/go-chi/chi/v5"func main() { r := chi.NewRouter()
r.Route("/webhooks", func(r chi.Router) { r.Post("/stripe", stripeHandler) r.Post("/github", githubHandler) r.Post("/shopify", shopifyHandler) })
log.Println("Server listening on :8080") http.ListenAndServe(":8080", r) } ```
Chi's middleware support lets you create reusable verification middleware:
func verifyWebhookMiddleware(secretEnv, headerName string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(body)) // restore bodysignature := r.Header.Get(headerName) secret := os.Getenv(secretEnv)
if !verifySignature(body, signature, secret) { http.Error(w, "Invalid signature", http.StatusUnauthorized) return }
next.ServeHTTP(w, r) }) } } ```
Testing with ReqPour
Run your Go server and the ReqPour relay:
go run main.go
# In another terminal:
npx reqpour relay --to http://localhost:8080Go's net/http/httptest package provides excellent testing support:
func TestWebhookHandler(t *testing.T) {
payload := `{"type": "payment.completed", "data": {"id": "pay_123"}}`req := httptest.NewRequest( http.MethodPost, "/webhooks", strings.NewReader(payload), ) req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder() webhookHandler(recorder, req)
if recorder.Code != http.StatusOK { t.Errorf("Expected 200, got %d", recorder.Code) } } ```
Capture real webhook payloads from the ReqPour dashboard and save them as test fixtures in your testdata/ directory. Go's test framework conventions make it easy to load and use these fixtures across your test suite.
Related
Get started with ReqPour
Catch, inspect, and relay webhooks to localhost. Free to start, $3/mo for Pro.