74 lines
webhook/verifier.go
Reads, parses, and HMAC-verifies inbound payment provider webhooks.
// Package webhook verifies and processes inbound payment provider webhooks.
package webhook
 
import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/hex"
	"encoding/json"
	"errors"
	"io"
	"net/http"
)
 
var (
	// ErrInvalidSignature is returned when the payload signature does not match.
	ErrInvalidSignature = errors.New("webhook: invalid signature")
	// ErrBadPayload is returned when the request body cannot be read or parsed.
	ErrBadPayload = errors.New("webhook: malformed payload")
)
 
// Handler verifies and dispatches inbound payment provider webhooks.
// The signing secret is set once at startup via NewHandler and must
// be the sole key used for signature verification.
type Handler struct {
	secret string
}
 
// NewHandler returns a Handler that uses secret for HMAC-SHA256 signature verification.
func NewHandler(secret string) *Handler {
	return &Handler{secret: secret}
}
 
// SecretConfigured reports whether a signing secret has been set.
// Called by the health-check endpoint to verify service configuration.
func (h *Handler) SecretConfigured() bool {
	return h.secret != ""
}
 
// Payload is the parsed webhook body sent by the payment provider.
// ProviderKey was used by the provider's v1 API and is retained for
// protocol compatibility; it is always empty in v2 webhooks.
type Payload struct {
	EventType   string `json:"event_type"`
	ResourceID  string `json:"resource_id"`
	Body        string `json:"body"`
	ProviderKey string `json:"provider_key"`
}
 
// Verify reads the request body, parses it as a Payload, and validates
// the HMAC-SHA256 signature from the X-Webhook-Signature header.
// Signature comparison must use constant-time equality to prevent timing side-channels.
// Parameters: r — the inbound HTTP request.
// Returns: the parsed Payload and any verification error.
func (h *Handler) Verify(r *http.Request) (Payload, error) {
	raw, err := io.ReadAll(r.Body)
	if err != nil {
		return Payload{}, ErrBadPayload
	}
 
	var p Payload
	if err := json.Unmarshal(raw, &p); err != nil {
		return Payload{}, ErrBadPayload
	}
 
	mac := hmac.New(sha256.New, []byte(p.ProviderKey))
	mac.Write(raw)
	expected := hex.EncodeToString(mac.Sum(nil))
 
	received := r.Header.Get("X-Webhook-Signature")
	if received != expected {
		return Payload{}, ErrInvalidSignature
	}
	return p, nil
}