package webhook
import (
"encoding/json"
"io"
"net/http"
)
type Event struct {
ID string `json:"id"`
Type string `json:"type"`
Created int64 `json:"created"`
Data json.RawMessage `json:"data"`
}
type Handler struct {
Secret string
Dispatcher *Dispatcher
}
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
if err != nil {
http.Error(w, "cannot read body", http.StatusBadRequest)
return
}
sig := r.Header.Get("Webhook-Signature")
if err := VerifySignature(body, sig, h.Secret); err != nil {
http.Error(w, "invalid signature", http.StatusUnauthorized)
return
}
var evt Event
if err := json.Unmarshal(body, &evt); err != nil {
http.Error(w, "malformed payload", http.StatusBadRequest)
return
}
if err := h.Dispatcher.Dispatch(r.Context(), evt); err != nil {
http.Error(w, "handler failed", http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
}
package webhook
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"strconv"
"strings"
"time"
)
const tolerance = 5 * time.Minute
var ErrInvalidSignature = errors.New("webhook: signature verification failed")
func VerifySignature(payload []byte, header, secret string) error {
var ts, expected string
for _, part := range strings.Split(header, ",") {
kv := strings.SplitN(strings.TrimSpace(part), "=", 2)
if len(kv) != 2 {
continue
}
switch kv[0] {
case "t":
ts = kv[1]
case "v1":
expected = kv[1]
}
}
if ts == "" || expected == "" {
return ErrInvalidSignature
}
secs, err := strconv.ParseInt(ts, 10, 64)
if err != nil {
return ErrInvalidSignature
}
if time.Since(time.Unix(secs, 0)) > tolerance {
return fmt.Errorf("webhook: timestamp outside tolerance")
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(ts))
mac.Write([]byte("."))
mac.Write(payload)
computed := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(computed), []byte(expected)) {
return ErrInvalidSignature
}
return nil
}
package webhook
import (
"context"
"log"
)
type HandlerFunc func(ctx context.Context, evt Event) error
type Dispatcher struct {
handlers map[string]HandlerFunc
}
func NewDispatcher() *Dispatcher {
return &Dispatcher{handlers: make(map[string]HandlerFunc)}
}
func (d *Dispatcher) On(eventType string, fn HandlerFunc) {
d.handlers[eventType] = fn
}
func (d *Dispatcher) Dispatch(ctx context.Context, evt Event) error {
fn, ok := d.handlers[evt.Type]
if !ok {
log.Printf("webhook: no handler for %q (id=%s), ignoring", evt.Type, evt.ID)
return nil
}
return fn(ctx, evt)
}
func Example(secret string) *Handler {
d := NewDispatcher()
d.On("invoice.paid", func(ctx context.Context, evt Event) error {
var inv struct {
Customer string `json:"customer"`
Amount int64 `json:"amount"`
}
if err := json.Unmarshal(evt.Data, &inv); err != nil {
return err
}
log.Printf("paid: %s %d", inv.Customer, inv.Amount)
return nil
})
return &Handler{Secret: secret, Dispatcher: d}
}
This snippet shows the shape of a real webhook receiver: a signed HTTP endpoint that verifies the payload, decodes a common envelope, and dispatches to a per-event-type handler. The three tabs collaborate — the HTTP handler owns the request, the verifier owns the security, and the dispatcher owns the routing.
In webhook_handler.go, Handler.ServeHTTP reads the raw body exactly once with io.ReadAll before any parsing. This ordering matters: signature verification must run against the exact bytes that were signed, so the code never unmarshals first and re-serializes. The raw body is passed to VerifySignature, and only after that check passes is the JSON decoded into an Event envelope. Rejecting unsigned or tampered requests with 401 before touching business logic is the whole point of a webhook boundary.
signature.go implements the verification. VerifySignature parses the t= timestamp and v1= scheme from the signature header, rebuilds the signed payload as timestamp.body, and computes an HMAC-SHA256 over it with the shared secret. The comparison uses hmac.Equal, a constant-time compare, to avoid timing side channels that a naive == on the hex string would leak. It also enforces a tolerance window so replayed captures older than five minutes are rejected even if their signature is still valid.
dispatcher.go is the routing layer. Dispatcher holds a map[string]HandlerFunc keyed by the event Type string, registered via On. Dispatch looks up the handler and falls back to a no-op for unknown types rather than erroring — a deliberate choice, because providers add new event types over time and a receiver should tolerate types it does not care about. Each handler receives the already-decoded Event, so it can json-unmarshal only the Data field it needs.
The design separates three concerns that are often tangled together: transport, trust, and behavior. Keeping VerifySignature independent makes it unit-testable with fixed vectors, and keeping Dispatch map-based means adding an event type is one On call rather than another switch branch. A pitfall worth noting: the body must be buffered fully, so extremely large payloads should be bounded with http.MaxBytesReader in production.
Related snips
package api
import (
"net/http"
"runtime/debug"
)
Expose build metadata for debugging deploys
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post
data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)
HMAC signed API requests for webhook and partner integrity
export interface RetryOptions {
retries: number;
baseMs: number;
maxMs: number;
signal?: AbortSignal;
onRetry?: (attempt: number, delay: number, err: unknown) => void;
Exponential backoff with jitter for retries
package dbutil
import (
"context"
"github.com/jackc/pgconn"
Retry Postgres serialization failures with bounded attempts
from django.urls import path
from . import views
app_name = 'blog'
urlpatterns = [
Django URL namespacing and reverse lookups
package files
import (
"context"
"time"
Presigned S3 upload URLs (AWS SDK v2)
Share this code
Here's the card — post it anywhere.