Verify Standard Webhooks (Polar) signatures in Go

· 3 min read

Polar, and a growing list of other services, sign webhooks with the Standard Webhooks scheme. Verifying it correctly takes about forty lines of Go and no dependencies. Here is the whole thing, then the mistakes I would check for in any implementation.

The verifier

package main

import (
	"crypto/hmac"
	"crypto/sha256"
	"encoding/base64"
	"errors"
	"strconv"
	"strings"
	"time"
)

// secret is the "whsec_<base64>" string from the provider's dashboard.
func VerifyStandardWebhook(secret string, body []byte, id, ts, sigHeader string, now time.Time, tolerance time.Duration) error {
	key, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(secret, "whsec_"))
	if err != nil {
		return errors.New("bad secret")
	}
	sec, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return errors.New("bad timestamp")
	}
	if d := now.Sub(time.Unix(sec, 0)); d > tolerance || d < -tolerance {
		return errors.New("timestamp outside tolerance") // blocks replays of old requests
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	want := mac.Sum(nil)
	// One or more space-separated "v1,<base64>" entries (key rotation).
	for _, part := range strings.Fields(sigHeader) {
		v, sig, ok := strings.Cut(part, ",")
		if !ok || v != "v1" {
			continue
		}
		if got, err := base64.StdEncoding.DecodeString(sig); err == nil && hmac.Equal(got, want) {
			return nil
		}
	}
	return errors.New("invalid signature")
}

And the handler around it:

func webhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) // raw bytes, size-capped
	if err != nil {
		http.Error(w, "too large", http.StatusRequestEntityTooLarge)
		return
	}
	err = VerifyStandardWebhook(os.Getenv("WEBHOOK_SECRET"), body,
		r.Header.Get("webhook-id"), r.Header.Get("webhook-timestamp"), r.Header.Get("webhook-signature"),
		time.Now(), 5*time.Minute)
	if err != nil {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	// Only now parse the JSON and act on it. Dedupe on r.Header.Get("webhook-id").
	w.WriteHeader(http.StatusAccepted)
}

I checked this code against the tested library inside the Go Webhook Receiver Starter, with eight cases (good signature, a rotation header with two signatures, tampered body, wrong id, stale and future timestamps, an ignored v1a entry, garbage). Both implementations agree on every case and give the expected result.

The five mistakes

  1. Verifying parsed JSON instead of the raw body. The signature covers the exact bytes sent. Decode and re-encode the JSON and the bytes change.
  2. Using == to compare signatures. Use hmac.Equal, which takes constant time.
  3. Forgetting the timestamp. Without a tolerance window a captured request is valid forever. The scheme signs the timestamp so you can reject old ones.
  4. Using the secret as raw text. The part after whsec_ is base64 and must be decoded first. (Polar's documentation says secrets created before 2026-09-08 use the UTF-8 bytes of the whole whsec_... string as the key instead. If verification fails for an older secret, try that variant. I have not tested against live Polar traffic, so confirm with one real delivery.)
  5. Treating a valid signature as "once only". A signature proves who sent a request, not that you have not seen it. Senders retry. Store the webhook-id with a uniqueness constraint and handle each id once.

Beyond the signature

Verification is the front door. A receiver also has to answer fast, store durably, process in the background with retries, and survive its own crash. That is the part that takes the rest of the work, and I wrote about it in receiving signed webhooks in Go.

Free module and the full starter

The verifier above is extracted, with GitHub-style and Stripe-style schemes and a test suite (including the published Standard Webhooks known-answer vector), as a small MIT-licensed module: see the free tools. The queue, dedupe, retries, secret rotation and operations tooling are in the Go Webhook Receiver Starter.