package webhooks
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"net/http"
)
func Verify(secret []byte, r *http.Request) ([]byte, error) {
sigHex := r.Header.Get("X-Signature-SHA256")
if sigHex == "" {
return nil, errors.New("missing signature")
}
sig, err := hex.DecodeString(sigHex)
if err != nil {
return nil, errors.New("bad signature encoding")
}
body, err := io.ReadAll(r.Body)
if err != nil {
return nil, err
}
_ = r.Body.Close()
r.Body = io.NopCloser(bytes.NewReader(body))
mac := hmac.New(sha256.New, secret)
mac.Write(body)
expected := mac.Sum(nil)
if !hmac.Equal(sig, expected) {
return nil, errors.New("invalid signature")
}
return body, nil
}
Webhook endpoints should assume the internet is hostile. I verify the request with an HMAC signature derived from the raw body and a shared secret, and I use hmac.Equal to avoid timing leaks. The key detail is reading the body exactly once: the server must compute the signature over the same bytes the client signed, so I read r.Body into a buffer, validate, then replace r.Body with a new reader if downstream code needs it. I also enforce a short clock skew window using an X-Timestamp header to reduce replay risk, and I log only the event_id, not the raw payload. This pattern turns “anyone can POST JSON” into an authenticated integration boundary.