Verify Standard Webhooks (Polar) signatures in Go
· 8 min read
On this page (10)
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
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.
| Header | Example | Meaning |
|---|---|---|
webhook-id | msg_p5jXN8AQM9LWM0D4loKWxJek | Unique per message, identical on every retry of it |
webhook-timestamp | 1614265330 | Unix seconds when the attempt was sent |
webhook-signature | v1,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.Equalruns 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
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.
- Verify the headers and body as above. On failure, answer 401 and stop.
- 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. - Process from your own table in the background. For
order.paid, read the order id and the customer email fromdata, create the licence row keyed on the order id, send the email from that row, and post one line to your sales channel. - 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
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 Webhooks | Stripe | GitHub | |
|---|---|---|---|
| Signed content | id.timestamp.body | timestamp.body | body |
| Encoding | base64 | hex | hex with sha256= prefix |
| Secret | base64 after whsec_ | the whole string as bytes | the string as bytes |
| Replay protection | timestamp header | timestamp in header | none built in |
| Multiple signatures | space-separated | repeated 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.