Back to Blog
Hook Mesh Team

Receive and Verify Webhooks in Go

Learn how to securely receive and verify webhooks in Go using HMAC-SHA256 signatures, timestamp validation, and timing-safe comparisons. Complete tutorial with production-ready code examples.

Receive and Verify Webhooks in Go

Receive and Verify Webhooks in Go

Go's standard library provides everything for secure webhook receivers: HTTP handling, cryptographic verification, and high-throughput performance. This tutorial covers building secure endpoints, HMAC-SHA256 verification, timestamp validation, and middleware patterns.

Setting Up the HTTP Handler

Read the raw request body before any parsing—you need exact bytes for signature verification.

package main

import (
    "io"
    "log"
    "net/http"
)

func main() {
    http.HandleFunc("/webhooks", handleWebhook)
    log.Println("Webhook server starting on :8080")
    log.Fatal(http.ListenAndServe(":8080", nil))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    // Only accept POST requests
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    // Read the entire request body
    body, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("Error reading body: %v", err)
        http.Error(w, "Error reading request", http.StatusBadRequest)
        return
    }
    defer r.Body.Close()

    log.Printf("Received webhook: %s", string(body))

    // Respond immediately
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received": true}`))
}

Use io.ReadAll to capture exact request bytes. Any modification—whitespace, key ordering—invalidates HMAC verification.

Reading and Preserving the Request Body

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    // Limit body size to prevent memory exhaustion attacks
    r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1MB limit

    body, err := io.ReadAll(r.Body)
    if err != nil {
        log.Printf("Error reading body: %v", err)
        http.Error(w, "Request body too large or unreadable", http.StatusBadRequest)
        return
    }

    // Get signature from header
    signature := r.Header.Get("X-Webhook-Signature")
    if signature == "" {
        http.Error(w, "Missing signature header", http.StatusUnauthorized)
        return
    }

    // Verify signature using the raw body bytes
    if !verifySignature(body, signature, webhookSecret) {
        http.Error(w, "Invalid signature", http.StatusUnauthorized)
        return
    }

    // Only parse JSON after verification succeeds
    var event WebhookEvent
    if err := json.Unmarshal(body, &event); err != nil {
        log.Printf("Error parsing JSON: %v", err)
        http.Error(w, "Invalid JSON payload", http.StatusBadRequest)
        return
    }

    // Process the verified event
    processEvent(event)

    w.WriteHeader(http.StatusOK)
}

http.MaxBytesReader prevents memory exhaustion attacks.

HMAC-SHA256 Signature Verification

HMAC-SHA256 verification uses Go's crypto/hmac, crypto/sha256, and crypto/subtle packages for timing-safe comparison.

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "errors"
    "strings"
)

var webhookSecret = []byte("whsec_your_secret_key_here")

func verifySignature(payload []byte, signatureHeader string, secret []byte) bool {
    // Parse the signature header (format: "v1=abc123...")
    parts := strings.Split(signatureHeader, "=")
    if len(parts) != 2 || parts[0] != "v1" {
        return false
    }

    receivedSig, err := hex.DecodeString(parts[1])
    if err != nil {
        return false
    }

    // Compute expected signature
    mac := hmac.New(sha256.New, secret)
    mac.Write(payload)
    expectedSig := mac.Sum(nil)

    // Timing-safe comparison
    return subtle.ConstantTimeCompare(receivedSig, expectedSig) == 1
}

Use subtle.ConstantTimeCompare—naive comparison returns early on mismatch, letting attackers discover signatures through timing. Constant-time comparison always takes the same duration.

Timestamp Validation for Replay Protection

Signature verification alone doesn't prevent replay attacks—an attacker who captures a legitimate webhook can resend it indefinitely. Including a timestamp in the signed payload and validating its freshness solves this problem.

Most webhook providers, including Hook Mesh, use a signature format that incorporates the timestamp:

import (
    "fmt"
    "strconv"
    "time"
)

const maxTimestampAge = 5 * time.Minute

func verifySignatureWithTimestamp(payload []byte, signatureHeader string, secret []byte) error {
    // Parse signature header (format: "t=1234567890,v1=abc123...")
    parts := strings.Split(signatureHeader, ",")
    if len(parts) < 2 {
        return errors.New("invalid signature format")
    }

    var timestamp int64
    var signature string

    for _, part := range parts {
        kv := strings.SplitN(part, "=", 2)
        if len(kv) != 2 {
            continue
        }
        switch kv[0] {
        case "t":
            ts, err := strconv.ParseInt(kv[1], 10, 64)
            if err != nil {
                return errors.New("invalid timestamp")
            }
            timestamp = ts
        case "v1":
            signature = kv[1]
        }
    }

    if timestamp == 0 || signature == "" {
        return errors.New("missing timestamp or signature")
    }

    // Validate timestamp freshness
    webhookTime := time.Unix(timestamp, 0)
    if time.Since(webhookTime).Abs() > maxTimestampAge {
        return errors.New("timestamp outside acceptable range")
    }

    // Reconstruct signed payload (timestamp + payload)
    signedPayload := fmt.Sprintf("%d.%s", timestamp, string(payload))

    // Compute expected signature
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(signedPayload))
    expectedSig := mac.Sum(nil)

    receivedSig, err := hex.DecodeString(signature)
    if err != nil {
        return errors.New("invalid signature encoding")
    }

    if subtle.ConstantTimeCompare(receivedSig, expectedSig) != 1 {
        return errors.New("signature mismatch")
    }

    return nil
}

A 5-minute tolerance accommodates clock drift while limiting replay attacks. For complete protection, store processed event IDs to build idempotent webhook handlers.

Responding Quickly to Webhooks

Acknowledge immediately (within 5-30 seconds), handle work asynchronously:

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)
    signature := r.Header.Get("X-Webhook-Signature")

    if err := verifySignatureWithTimestamp(body, signature, webhookSecret); err != nil {
        log.Printf("Verification failed: %v", err)
        http.Error(w, "Unauthorized", http.StatusUnauthorized)
        return
    }

    // Respond immediately before processing
    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received": true}`))

    // Process asynchronously
    go func() {
        var event WebhookEvent
        if err := json.Unmarshal(body, &event); err != nil {
            log.Printf("Error parsing event: %v", err)
            return
        }
        processEvent(event)
    }()
}

Use job queues for production (not bare goroutines).

Middleware Pattern for Verification

Use middleware to centralize verification and ensure it's never accidentally skipped:

package main

import (
    "bytes"
    "context"
    "io"
    "net/http"
)

type contextKey string

const bodyKey contextKey = "webhookBody"

func webhookVerificationMiddleware(secret []byte) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            // Read and preserve body
            body, err := io.ReadAll(r.Body)
            if err != nil {
                http.Error(w, "Error reading request", http.StatusBadRequest)
                return
            }
            r.Body.Close()

            // Verify signature
            signature := r.Header.Get("X-Webhook-Signature")
            if err := verifySignatureWithTimestamp(body, signature, secret); err != nil {
                http.Error(w, "Invalid signature", http.StatusUnauthorized)
                return
            }

            // Store body in context for handler access
            ctx := context.WithValue(r.Context(), bodyKey, body)
            r.Body = io.NopCloser(bytes.NewReader(body))

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func getWebhookBody(r *http.Request) []byte {
    body, _ := r.Context().Value(bodyKey).([]byte)
    return body
}

Using this middleware with your routes:

func main() {
    mux := http.NewServeMux()

    // Apply verification middleware to webhook routes
    webhookHandler := webhookVerificationMiddleware(webhookSecret)(
        http.HandlerFunc(handleVerifiedWebhook),
    )

    mux.Handle("/webhooks/", webhookHandler)
    mux.HandleFunc("/health", healthCheck)

    log.Fatal(http.ListenAndServe(":8080", mux))
}

func handleVerifiedWebhook(w http.ResponseWriter, r *http.Request) {
    // Signature already verified by middleware
    body := getWebhookBody(r)

    var event WebhookEvent
    json.Unmarshal(body, &event)

    // Handle the verified event
    switch event.Type {
    case "payment.completed":
        handlePaymentCompleted(event)
    case "subscription.canceled":
        handleSubscriptionCanceled(event)
    }

    w.WriteHeader(http.StatusOK)
}

Hook Mesh Signature Verification

func verifyHookMeshSignature(payload []byte, signatureHeader string) error {
    secret := os.Getenv("HOOKMESH_WEBHOOK_SECRET")
    if secret == "" {
        return errors.New("webhook secret not configured")
    }

    return verifySignatureWithTimestamp(payload, signatureHeader, []byte(secret))
}

Hook Mesh includes event IDs, making idempotency straightforward. Combined with automatic retries and delivery guarantees, you get reliable delivery without building infrastructure.

Complete Production Example

package main

import (
    "crypto/hmac"
    "crypto/sha256"
    "crypto/subtle"
    "encoding/hex"
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "log"
    "net/http"
    "os"
    "strconv"
    "strings"
    "time"
)

type WebhookEvent struct {
    ID        string          `json:"id"`
    Type      string          `json:"type"`
    Timestamp int64           `json:"timestamp"`
    Data      json.RawMessage `json:"data"`
}

func main() {
    secret := os.Getenv("WEBHOOK_SECRET")
    if secret == "" {
        log.Fatal("WEBHOOK_SECRET environment variable required")
    }

    mux := http.NewServeMux()
    handler := webhookVerificationMiddleware([]byte(secret))(
        http.HandlerFunc(handleWebhook),
    )
    mux.Handle("/webhooks", handler)

    log.Println("Starting webhook server on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}

func handleWebhook(w http.ResponseWriter, r *http.Request) {
    body := getWebhookBody(r)

    var event WebhookEvent
    if err := json.Unmarshal(body, &event); err != nil {
        http.Error(w, "Invalid JSON", http.StatusBadRequest)
        return
    }

    log.Printf("Processing event %s: %s", event.ID, event.Type)

    w.WriteHeader(http.StatusOK)
    w.Write([]byte(`{"received": true}`))
}

Conclusion

Building secure webhook receivers in Go requires: reading raw request bytes, HMAC-SHA256 verification, subtle.ConstantTimeCompare for timing-safe comparison, and timestamp validation. The middleware pattern centralizes verification.

Go's standard library provides everything needed: io.ReadAll, crypto/hmac, crypto/sha256, and crypto/subtle. Combined with fast response times and async processing, you have production-grade webhook handling.

Hook Mesh provides automatic signature generation, timestamp inclusion, and reliable delivery with retries. To send webhooks from Go, see our sending webhooks with Go guide. For broader security patterns, read our webhook security best practices. For more language tutorials, explore our webhook implementation guides.

Related Posts