Bonaventure OgetoBy Bonaventure Ogeto|

Handle Paystack Webhooks in Go

To handle Paystack webhooks in Go, create an HTTP handler that reads the raw body with io.ReadAll, computes an HMAC SHA-512 hash using crypto/hmac, compares it to the X-Paystack-Signature header with hmac.Equal, and processes the event. Return a 200 status.

Webhook Handler

import (
    "crypto/hmac"
    "crypto/sha512"
    "encoding/hex"
    "encoding/json"
    "io"
    "log"
    "net/http"
)

func webhookHandler(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
        return
    }

    signature := r.Header.Get("X-Paystack-Signature")
    if signature == "" {
        http.Error(w, "Missing signature", http.StatusBadRequest)
        return
    }

    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "Failed to read body", http.StatusBadRequest)
        return
    }

    mac := hmac.New(sha512.New, []byte(paystackSecret))
    mac.Write(body)
    expectedMAC := hex.EncodeToString(mac.Sum(nil))

    if !hmac.Equal([]byte(expectedMAC), []byte(signature)) {
        http.Error(w, "Invalid signature", http.StatusBadRequest)
        return
    }

    var event struct {
        Event string          "json:\"event\""
        Data  json.RawMessage "json:\"data\""
    }
    json.Unmarshal(body, &event)

    // Process async
    go processEvent(event.Event, event.Data)

    w.WriteHeader(http.StatusOK)
    w.Write([]byte("OK"))
}

func processEvent(eventType string, data json.RawMessage) {
    switch eventType {
    case "charge.success":
        var txn struct {
            Reference string "json:\"reference\""
        }
        json.Unmarshal(data, &txn)
        if txn.Reference != "" {
            if err := verifyAndFulfill(txn.Reference, db); err != nil {
                log.Printf("Fulfillment error: %v", err)
            }
        }
    case "subscription.create":
        log.Println("New subscription created")
    case "invoice.payment_failed":
        log.Println("Invoice payment failed")
    default:
        log.Printf("Unhandled event: %s", eventType)
    }
}

Route Registration

func main() {
    http.HandleFunc("/api/pay", initializeHandler)
    http.HandleFunc("/payment/callback", callbackHandler)
    http.HandleFunc("/webhooks/paystack", webhookHandler)

    log.Println("Server running on :8080")
    http.ListenAndServe(":8080", nil)
}

Testing

func TestWebhookHandler(t *testing.T) {
    payload := []byte("{\"event\":\"charge.success\",\"data\":{\"reference\":\"test\"}}")

    mac := hmac.New(sha512.New, []byte("sk_test_xxx"))
    mac.Write(payload)
    sig := hex.EncodeToString(mac.Sum(nil))

    req := httptest.NewRequest("POST", "/webhooks/paystack", bytes.NewBuffer(payload))
    req.Header.Set("X-Paystack-Signature", sig)
    req.Header.Set("Content-Type", "application/json")

    rr := httptest.NewRecorder()
    webhookHandler(rr, req)

    if rr.Code != http.StatusOK {
        t.Errorf("Expected 200, got %d", rr.Code)
    }
}

Ship Payments Faster

Key Takeaways

  • Use io.ReadAll(r.Body) for the raw bytes needed for HMAC verification.
  • Go crypto/hmac and crypto/sha512 handle signature computation.
  • Use hmac.Equal for constant-time comparison to prevent timing attacks.
  • Launch event processing in a goroutine for async handling after returning 200.
  • Make handlers idempotent. Paystack retries failed deliveries.
  • Go is well-suited for high-throughput webhook processing.

Frequently Asked Questions

Is goroutine processing safe for webhooks?
Yes. Goroutines are lightweight and safe to spawn per request. The idempotent database update handles concurrency. Make sure your database operations are goroutine-safe.
Should I use a message queue instead of goroutines?
For simple processing, goroutines work well. For complex workflows or guaranteed delivery, use a message queue like NATS or RabbitMQ.
How do I test webhooks locally?
Use ngrok to expose your local server, or compute the HMAC in your test and use httptest for unit tests.

Ready to build real-world apps?

Join the McTaba Labs full-stack marathon (4 months full-time · 6 months part-time). Learn M-Pesa, USSD, and WhatsApp engineering while shipping 8 production apps.

Apply to the McTaba Marathon