Back to Blog
Hook Mesh Team

Send Webhooks with Go: A Complete Tutorial

Learn how to send webhooks with Go using the standard library. This hands-on tutorial covers JSON marshaling, HMAC-SHA256 signatures, HTTP client configuration, timeouts, retries, and production-ready patterns.

Send Webhooks with Go: A Complete Tutorial

Send Webhooks with Go: A Complete Tutorial

Go's standard library provides everything for building webhook delivery systems: HTTP clients, JSON marshaling, and cryptographic functions. This tutorial builds a complete system from scratch, then shows how Hook Mesh simplifies production workloads.

Setting Up a Basic Webhook Sender

package main

import (
    "bytes"
    "encoding/json"
    "fmt"
    "net/http"
    "time"
)

type WebhookPayload struct {
    Event     string                 `json:"event"`
    Timestamp time.Time              `json:"timestamp"`
    Data      map[string]interface{} `json:"data"`
}

func sendWebhook(url string, payload WebhookPayload) error {
    jsonData, err := json.Marshal(payload)
    if err != nil {
        return fmt.Errorf("failed to marshal payload: %w", err)
    }

    resp, err := http.Post(url, "application/json", bytes.NewBuffer(jsonData))
    if err != nil {
        return fmt.Errorf("failed to send webhook: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return fmt.Errorf("webhook failed with status: %d", resp.StatusCode)
    }

    return nil
}

This basic implementation works but lacks critical features for production use. Let's enhance it step by step.

JSON Marshaling Best Practices

type OrderEvent struct {
    Event     string    `json:"event"`
    Timestamp time.Time `json:"timestamp"`
    Data      OrderData `json:"data"`
}

type OrderData struct {
    OrderID    string  `json:"order_id"`
    CustomerID string  `json:"customer_id"`
    Amount     float64 `json:"amount"`
    Currency   string  `json:"currency"`
    Status     string  `json:"status"`
}

func createOrderPayload(orderID, customerID string, amount float64) ([]byte, error) {
    event := OrderEvent{
        Event:     "order.completed",
        Timestamp: time.Now().UTC(),
        Data: OrderData{
            OrderID:    orderID,
            CustomerID: customerID,
            Amount:     amount,
            Currency:   "USD",
            Status:     "completed",
        },
    }

    return json.Marshal(event)
}

Use UTC timestamps and event types for routing.

Adding HMAC-SHA256 Signatures

HMAC-SHA256 signatures let recipients verify payload authenticity and integrity.

import (
    "crypto/hmac"
    "crypto/sha256"
    "encoding/hex"
)

func generateSignature(payload []byte, secret string) string {
    mac := hmac.New(sha256.New, []byte(secret))
    mac.Write(payload)
    return hex.EncodeToString(mac.Sum(nil))
}

func sendSignedWebhook(url string, payload []byte, secret string) error {
    signature := generateSignature(payload, secret)

    req, err := http.NewRequest("POST", url, bytes.NewBuffer(payload))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Webhook-Signature", signature)
    req.Header.Set("X-Webhook-Timestamp", fmt.Sprintf("%d", time.Now().Unix()))

    client := &http.Client{Timeout: 30 * time.Second}
    resp, err := client.Do(req)
    if err != nil {
        return fmt.Errorf("failed to send webhook: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return fmt.Errorf("webhook failed with status: %d", resp.StatusCode)
    }

    return nil
}

Timestamp inclusion prevents replay attacks—reject old timestamps.

HTTP Client Configuration

Production systems need fine-tuned settings: connection pooling, timeouts, and keep-alives significantly impact performance at scale.

func createWebhookClient() *http.Client {
    transport := &http.Transport{
        MaxIdleConns:        100,
        MaxIdleConnsPerHost: 10,
        IdleConnTimeout:     90 * time.Second,
        DisableCompression:  true,
    }

    return &http.Client{
        Transport: transport,
        Timeout:   30 * time.Second,
    }
}

Reuse clients—connection reuse reduces latency and resource use.

Context Usage for Timeouts

Use Go's context package for granular control over request lifecycles, timeouts, and cancellation.

import "context"

func sendWebhookWithContext(ctx context.Context, client *http.Client, url string, payload []byte, secret string) error {
    ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
    defer cancel()

    signature := generateSignature(payload, secret)

    req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewBuffer(payload))
    if err != nil {
        return fmt.Errorf("failed to create request: %w", err)
    }

    req.Header.Set("Content-Type", "application/json")
    req.Header.Set("X-Webhook-Signature", signature)

    resp, err := client.Do(req)
    if err != nil {
        if ctx.Err() == context.DeadlineExceeded {
            return fmt.Errorf("webhook timed out")
        }
        return fmt.Errorf("failed to send webhook: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode >= 300 {
        return fmt.Errorf("webhook failed with status: %d", resp.StatusCode)
    }

    return nil
}

Context timeouts prevent resource leaks in complex workflows.

Implementing Retries with Exponential Backoff

A robust retry mechanism with exponential backoff handles transient failures gracefully.

func sendWebhookWithRetry(ctx context.Context, client *http.Client, url string, payload []byte, secret string, maxRetries int) error {
    var lastErr error

    for attempt := 0; attempt <= maxRetries; attempt++ {
        if attempt > 0 {
            backoff := time.Duration(1<<uint(attempt-1)) * time.Second
            select {
            case <-time.After(backoff):
            case <-ctx.Done():
                return fmt.Errorf("context cancelled during retry: %w", ctx.Err())
            }
        }

        err := sendWebhookWithContext(ctx, client, url, payload, secret)
        if err == nil {
            return nil
        }

        lastErr = err

        // Don't retry client errors (4xx)
        if isClientError(err) {
            return err
        }
    }

    return fmt.Errorf("webhook failed after %d attempts: %w", maxRetries+1, lastErr)
}

func isClientError(err error) bool {
    // Check if error message contains 4xx status codes
    errStr := err.Error()
    return len(errStr) > 0 &&
           (contains(errStr, "status: 4") || contains(errStr, "status code: 4"))
}

Limit retries to server errors and network failures. Client errors (4xx) indicate problems with the request itself and won't succeed on retry.

Async Webhook Delivery with Goroutines

Goroutines enable concurrent delivery without blocking your main application flow.

type WebhookJob struct {
    URL     string
    Payload []byte
    Secret  string
}

func startWebhookWorkers(ctx context.Context, jobs <-chan WebhookJob, workers int) {
    client := createWebhookClient()

    for i := 0; i < workers; i++ {
        go func(workerID int) {
            for {
                select {
                case job, ok := <-jobs:
                    if !ok {
                        return
                    }
                    err := sendWebhookWithRetry(ctx, client, job.URL, job.Payload, job.Secret, 3)
                    if err != nil {
                        log.Printf("Worker %d: webhook failed: %v", workerID, err)
                    }
                case <-ctx.Done():
                    return
                }
            }
        }(i)
    }
}

Use buffered channels as job queues with worker pools to prevent overwhelming endpoints.

Error Handling Patterns

Categorize errors to determine appropriate responses and retry behavior.

type WebhookError struct {
    Type       string
    StatusCode int
    Message    string
    Retryable  bool
}

func (e *WebhookError) Error() string {
    return fmt.Sprintf("%s: %s (status: %d)", e.Type, e.Message, e.StatusCode)
}

func categorizeError(resp *http.Response, err error) *WebhookError {
    if err != nil {
        return &WebhookError{
            Type:      "network",
            Message:   err.Error(),
            Retryable: true,
        }
    }

    switch {
    case resp.StatusCode >= 500:
        return &WebhookError{
            Type:       "server",
            StatusCode: resp.StatusCode,
            Message:    "recipient server error",
            Retryable:  true,
        }
    case resp.StatusCode >= 400:
        return &WebhookError{
            Type:       "client",
            StatusCode: resp.StatusCode,
            Message:    "invalid request",
            Retryable:  false,
        }
    default:
        return nil
    }
}

Log errors and expose metrics for monitoring.

Using Hook Mesh for Production Webhooks

Hook Mesh provides retry queues, dead letter storage, monitoring, and rate limiting out of the box.

import "github.com/hookmesh/hookmesh-go"

func main() {
    client := hookmesh.NewClient("your-api-key")

    event := hookmesh.Event{
        Type: "order.completed",
        Data: map[string]interface{}{
            "order_id": "ord_12345",
            "amount":   99.99,
        },
    }

    err := client.Send(context.Background(), event)
    if err != nil {
        log.Fatal(err)
    }
}

Hook Mesh handles signature generation, retries, and delivery tracking automatically. The dashboard provides visibility into delivery status, and failed webhooks are stored for replay.

Conclusion

Go's standard library provides everything for webhook delivery systems: JSON marshaling, HMAC-SHA256 signatures, HTTP client configuration, timeouts, retries, and async patterns. The DIY approach works well for development; Hook Mesh handles complexity at scale.

Learn how recipients verify webhooks in Go to understand the full integration flow. For more language tutorials, explore our webhook implementation guides.

Related Posts