Webhooks

Verify Standard Webhooks (Polar) signatures in Go

· 8 min read

On this page (10)
  1. Who needs this
  2. What the scheme sends
  3. The verifier
  4. Testing it against a known answer
  5. Polar specifics
  6. From a verified request to a paid order
  7. One verifier, several senders
  8. Mistakes we see
  9. Comparison with the other two schemes
  10. FAQ

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, but there are several places where a plausible implementation is quietly wrong. This post builds the verifier, hardens it, and shows how to test it against a published vector so you do not have to trust your own reading of the spec.

What the verifier checks

Three headers and the raw body feed a verifier that checks the timestamp window, recomputes the HMAC over id, timestamp and body, and compares it with each v1 entry.Headersid, timestamp, signatureRaw bodyexact bytesTimestamp window5 minutesHMAC-SHA256id.timestamp.bodyComparehmac.Equal, each v1 entry

Who needs this

A seller using Polar for a digital product. An order.paid event should grant a download or a licence. If your verifier is wrong in the permissive direction, anyone who finds your endpoint can POST a fake paid order and get a free licence. If it is wrong in the strict direction, real orders bounce with 401 and Polar's retries hide the problem until a customer writes in.

An app that accepts webhooks from several senders. Today it is Polar, next month a billing tool or a status service that also follows the standard. One verifier with a secret per source beats a pile of copy-pasted HMAC snippets, each with its own subtle bug.

A team rotating a leaked or aging secret. Someone pasted the signing secret into a chat. You need to switch to a new one without dropping deliveries that were signed with the old one a moment ago. The scheme was designed for this, but only if your code reads the whole signature header.

What the scheme sends

Each delivery carries three headers.

HeaderExampleMeaning
webhook-idmsg_p5jXN8AQM9LWM0D4loKWxJekUnique per message, identical on every retry of it
webhook-timestamp1614265330Unix seconds when the attempt was sent
webhook-signaturev1,g0hM9Se...=One or more space-separated version,base64 entries

The signed content is the three values joined with dots, with the raw body at the end:

<webhook-id>.<webhook-timestamp>.<raw body>

The signature is HMAC-SHA256 of that string, base64-encoded, using a key you get from the sender's dashboard as whsec_<base64>. The part after whsec_ is base64 and must be decoded to get the key bytes. The version prefix v1 means a symmetric signature. Entries starting with v1a are ed25519 asymmetric signatures and are a different algorithm, so a symmetric verifier must ignore them.

The verifier

package webhooksig

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

var ErrInvalid = errors.New("invalid signature")

// ParseSecret turns "whsec_<base64>" into key bytes.
func ParseSecret(s string) ([]byte, error) {
	b64, ok := strings.CutPrefix(s, "whsec_")
	if !ok {
		return nil, errors.New("secret must start with whsec_")
	}
	key, err := base64.StdEncoding.DecodeString(b64)
	if err != nil || len(key) == 0 {
		return nil, errors.New("whsec_ secret is not valid base64")
	}
	return key, nil
}

func Verify(key, body []byte, id, ts, header string, now time.Time, tolerance time.Duration) error {
	if id == "" {
		return ErrInvalid
	}
	unix, err := strconv.ParseInt(ts, 10, 64)
	if err != nil {
		return ErrInvalid
	}
	if d := now.Sub(time.Unix(unix, 0)); d > tolerance || d < -tolerance {
		return ErrInvalid // too old (replay) or too far in the future (clock skew)
	}
	mac := hmac.New(sha256.New, key)
	mac.Write([]byte(id + "." + ts + "."))
	mac.Write(body)
	want := mac.Sum(nil)

	entries := strings.Fields(header)
	if len(entries) == 0 || len(entries) > 16 {
		return ErrInvalid
	}
	ok := false
	for _, e := range entries {
		ver, b64, found := strings.Cut(e, ",")
		if !found || ver != "v1" {
			continue
		}
		if got, err := base64.StdEncoding.DecodeString(b64); err == nil && hmac.Equal(got, want) {
			ok = true // keep looping so timing does not reveal which entry matched
		}
	}
	if !ok {
		return ErrInvalid
	}
	return nil
}

Four details are worth a sentence each.

  • No concatenation. The HMAC is fed the id, timestamp and body separately, which avoids a copy of a large body.
  • Constant-time compare. hmac.Equal runs after decoding the header's base64, so equal-length byte slices are compared rather than strings.
  • Capped entries. A header with ten thousand space-separated tokens cannot burn CPU.
  • Plain rejections. An unparseable timestamp or an empty id is a normal rejection, not a panic.

The handler around it

func webhook(w http.ResponseWriter, r *http.Request) {
	body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20))
	if err != nil {
		http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
		return
	}
	for _, key := range keys { // current secret first, previous one during a rotation
		err = webhooksig.Verify(key, body,
			r.Header.Get("webhook-id"),
			r.Header.Get("webhook-timestamp"),
			r.Header.Get("webhook-signature"),
			time.Now(), 5*time.Minute)
		if err == nil {
			break
		}
	}
	if err != nil {
		http.Error(w, "invalid signature", http.StatusUnauthorized)
		return
	}
	// Only now parse JSON. Dedupe on r.Header.Get("webhook-id").
	w.WriteHeader(http.StatusAccepted)
}

Load keys once at startup from an environment variable, not on every request, and refuse to start if it is empty. An empty key still produces a valid HMAC, so a missing secret that silently becomes an empty one would let anyone sign requests.

Testing it against a known answer

A verifier that agrees with your signer proves nothing, because both can share the same misunderstanding. Use a vector produced by someone else. The Standard Webhooks project's reference Go library has one in its tests, and we use it in the starter:

const (
	secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw"
	id     = "msg_p5jXN8AQM9LWM0D4loKWxJek"
	ts     = "1614265330"
	body   = `{"test": 2432232314}`
	sig    = "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE="
)

func TestKnownVector(t *testing.T) {
	key, err := ParseSecret(secret)
	if err != nil {
		t.Fatal(err)
	}
	now := time.Unix(1614265330, 0) // freeze the clock at the vector's timestamp
	if err := Verify(key, []byte(body), id, ts, sig, now, 5*time.Minute); err != nil {
		t.Fatalf("published vector rejected: %v", err)
	}
}

Passing a now argument, instead of calling time.Now() inside, is what makes this testable. Add negative cases next to it: change one byte of the body, swap the id, shift the timestamp by a second, move now ten minutes later, put two entries in the header with the good one second, and put a v1a,... entry first. Each of those should give the result you expect, and the last two are the ones people forget.

You can also sign a request from the shell to try the whole endpoint:

KEY=$(printf '%s' "${WEBHOOK_SECRET#whsec_}" | base64 -d | xxd -p -c 256)
ID=msg_1; TS=$(date +%s); B='{"type":"order.paid"}'
SIG=$(printf '%s.%s.%s' "$ID" "$TS" "$B" | openssl dgst -sha256 -mac HMAC -macopt hexkey:$KEY -binary | base64)
curl -i -X POST localhost:8080/webhook/polar \
  -H "webhook-id: $ID" -H "webhook-timestamp: $TS" -H "webhook-signature: v1,$SIG" -d "$B"

Polar specifics

Polar's documentation says that secrets created before 2026-09-08 use the UTF-8 bytes of the entire whsec_... string as the HMAC key, while secrets created from that date follow the specification and base64-decode the part after the prefix. If a verifier that matches the spec rejects every delivery from an older Polar webhook, that is the first thing to try. The starter accepts a raw: prefix on a configured secret for this reason, and because it tries every configured secret, listing both variants works:

SIGNATURE_SCHEME=standard
WEBHOOK_SECRET=whsec_abc,raw:whsec_abc

Secret handling in Polar

Two cards showing how Polar secrets are turned into HMAC keys: older secrets use the whole whsec string as bytes, newer ones base64-decode the part after the prefix.Created before 2026-09-08Key is the UTF-8 bytes of the whole whsec_stringConfigure with the raw: prefixCreated from 2026-09-08Key is base64-decoded part after whsec_Follows the specification

Events carry an envelope of type, timestamp and data. For a sale you act on order.paid; order.created, order.updated and order.refunded carry the same order object and are usually acknowledged and ignored unless you revoke access on refund. Look at one real delivery in the provider's dashboard before you rely on field names.

From a verified request to a paid order

Verification answers "who sent this". What you do next decides whether a sale turns into a licence exactly once. A typical Polar flow looks like this, and each step has a reason.

  1. Verify the headers and body as above. On failure, answer 401 and stop.
  2. Insert the event under (source, webhook-id) with a unique constraint, then answer 202. A retry of the same message hits the constraint and is acknowledged without a second insert.
  3. Process from your own table in the background. For order.paid, read the order id and the customer email from data, create the licence row keyed on the order id, send the email from that row, and post one line to your sales channel.
  4. Ignore the rest politely. Event types you do not handle get a normal acknowledgement, otherwise the sender keeps retrying them and your dashboard shows a wall of failures.

Verified request to one licence

Four steps after a request arrives: verify, insert under source and webhook id, process in the background, and ignore event types you do not handle.1Verify401 on failure2Insert, 202unique onsource and id3Process laterlicence keyedon order id4Ignore therestacknowledgeunknown types

The reason for step 2 before step 3 is the sender's timeout. If the licence email takes eight seconds because the mail API is slow, the sender may give up and retry while your first attempt is still running. With the event already stored, the retry is a cheap duplicate and the slow work happens once.

A short handler for step 3 shows the shape. Notice that it never trusts the order to be new, and that the amount arrives in the currency's smallest unit:

func fulfil(ctx context.Context, o Order) error {
	if !o.Paid {
		return nil
	}
	created, err := licenses.CreateOnce(ctx, o.ID, o.Customer.Email) // unique on order id
	if err != nil {
		return err // transient: the worker retries with backoff
	}
	if created {
		notify(fmt.Sprintf("sale %s: %.2f %s", o.ID, float64(o.TotalAmount)/100, o.Currency))
	}
	return sendLicenceEmail(ctx, o.ID) // reads the stored licence, safe to repeat
}

Division by 100 is right for dollars and euros and wrong for currencies without minor units, so use a proper money type if you sell in more than a couple of currencies. The point of the snippet is the order of operations: create once, notify only when it was newly created, and make the email step read from stored state so a repeat sends the same key and not a new one.

One verifier, several senders

If more than one service sends you signed events, keep the verification code identical and vary only the configuration. Route by a path segment such as /webhook/polar and /webhook/billing, and look up the accepted keys for that source from a map built at startup. The path segment also becomes the first half of the dedupe key, so two senders that happen to reuse an id never collide. Resist the urge to write a per-sender verifier: the algorithm is the same, and the only things that differ are the keys, the tolerance and what you do with the payload afterwards.

Be strict about what a source is allowed to do. An unknown path should return 404 before any body is read, and a request to /webhook/polar should never be checked against another source's keys. Otherwise a leaked secret for a low-value integration can be used to forge events on a high-value one.

Mistakes we see

Verifying parsed JSON instead of the raw body. The signature covers the exact bytes. Decode and re-encode and they change. Read the body first, verify, then parse.

Comparing with ==. It returns at the first differing byte, which can leak information over many attempts. Use hmac.Equal.

Skipping the timestamp. Without a tolerance window a captured request is valid forever. The timestamp is inside the signed content, so an attacker cannot refresh it. Five minutes is the usual choice and it assumes your server clock is right; check that timedatectl reports the clock as synchronized.

Using the secret as text. The part after whsec_ is base64. Using it undecoded gives a different key and every signature fails.

Treating a valid signature as "once only". A signature proves the sender, not novelty. Store webhook-id with a uniqueness constraint. See webhook idempotency and dedupe patterns.

Verifying a body a proxy changed. Anything that rewrites the body, such as a gateway that normalises JSON, breaks every scheme. Pass the bytes through untouched.

Comparison with the other two schemes

Standard WebhooksStripeGitHub
Signed contentid.timestamp.bodytimestamp.bodybody
Encodingbase64hexhex with sha256= prefix
Secretbase64 after whsec_the whole string as bytesthe string as bytes
Replay protectiontimestamp headertimestamp in headernone built in
Multiple signaturesspace-separatedrepeated v1=not applicable

The full comparison, with the places where it bites, is in webhook signature pitfalls.

FAQ

Do I need the official Standard Webhooks library?

No. It exists for several languages and is fine to use. The algorithm is small enough that a stdlib implementation with a known-answer test is a reasonable choice if you want zero dependencies, which is common for a service that ships as one static binary.

Why does verification pass locally but fail in production?

Usually the body differs. A reverse proxy, a framework middleware or a JSON-binding step has touched it. The second most common cause is a wrong secret variant, for example a Polar secret created before 2026-09-08 that needs the raw: treatment.

What tolerance should I use?

Five minutes is the common default. A shorter window shrinks the replay opportunity but breaks if your clock drifts. If you also dedupe on webhook-id, a replay inside the window is harmless anyway.

How do I rotate the signing secret without losing events?

Create the new secret at the sender, add it to your list of accepted secrets, deploy, switch the sender over, then remove the old one after in-flight retries have passed. The sender may include signatures for both secrets in one header during its own rotation window, which is why the verifier loops over every entry.

Does this handle ed25519 v1a signatures?

No. Only symmetric v1 entries are verified and v1a entries are skipped. If a sender offers only asymmetric signatures you need an ed25519 check with its public key.

Verification is only the front door. Answering fast, storing durably and processing with retries is the rest of the job, covered in receiving signed webhooks in Go. The Go Webhook Receiver Starter includes a tested verifier for all three schemes, secret rotation and the queue behind it.

gowebhookssecurity