Golang: verify a webhook signature with HMAC and hmac.Equal

Verify a Sume webhook in Go: read the body once with io.ReadAll, HMAC-SHA256 the timestamp and raw bytes, then hmac.Equal each sume-v1 entry.

5 min readSume
All posts

To verify a Sume webhook signature in Go, read the body once with io.ReadAll(http.MaxBytesReader(...)), compute HMAC-SHA256 over <timestamp>.<raw_body> with hmac.New(sha256.New, secret), then hex-decode each comma-separated sume-v1= entry of the X-Sume-Webhook-Signature header and compare it with the MAC using hmac.Equal. Reject a timestamp outside your replay window, and unmarshal JSON only from the bytes you verified.

Go facts come from the standard library docs for crypto/hmac, net/http, io, and encoding/hex; Sume facts come from Run webhooks, Webhooks, and Verifying webhooks. All were read on 2026-09-27. Sume has no Go SDK: @sume-com/sdk is TypeScript, and the docs spell out the scheme for receivers in other languages. The Node version, with Express's body limit, is in Express raw body for webhook signatures.

Which Go calls does each step of the check use?

Everything comes from the standard library:

From Go's crypto/hmac, net/http, and encoding/hex docs and Sume's Run webhooks and Webhooks pages, read 2026-09-27.
StepSume ruleGo
Raw bodyVerify the raw bytes before any JSON parseio.ReadAll(http.MaxBytesReader(w, r.Body, n))
Headersx-sume-webhook-timestamp and x-sume-webhook-signaturer.Header.Get(...), which is case-insensitive and returns "" when the header is missing
MACHMAC-SHA256 over <timestamp>.<raw_body>hmac.New(sha256.New, secret), then Write and Sum(nil)
Signaturesume-v1=<hex>; during a rotation, one entry per live secret, comma-separatedstrings.Split on commas, then hex.DecodeString on the part after sume-v1=
CompareConstant time; accept any matching entryhmac.Equal(got, expected) for every entry
Replay windowReject timestamps outside it; five minutes is a reasonable defaultCompare the parsed timestamp with time.Now().Unix()

How do I write the verifier with crypto/hmac?

Go's crypto/hmac docs tell receivers to compare MACs with hmac.Equal to avoid timing side-channels; Equal compares two MACs without leaking timing information. The function compares raw MAC bytes rather than hex strings: hex.DecodeString expects only hexadecimal characters and an even length, and the loop skips any entry that fails to decode. It checks every entry, because for 24 hours after a secret rotation Sume sends one per live secret, newest first. Like Sume's TypeScript verifier, it refuses an empty secret, so an unset environment variable cannot become an empty HMAC key that anyone could compute. It needs crypto/hmac, crypto/sha256, encoding/hex, strconv, strings, and time.

func verifySume(body []byte, ts, header string, secret []byte) bool {
    t, err := strconv.ParseInt(ts, 10, 64)
    now := time.Now().Unix()
    if len(secret) == 0 || err != nil || t < now-300 || t > now+300 { // five-minute window
        return false
    }
    mac := hmac.New(sha256.New, secret)
    mac.Write([]byte(strconv.FormatInt(t, 10) + "."))
    mac.Write(body) // the raw bytes, never re-encoded JSON
    expected := mac.Sum(nil)
    ok := false
    for _, entry := range strings.Split(header, ",") { // two entries during a rotation
        sig, found := strings.CutPrefix(strings.TrimSpace(entry), "sume-v1=")
        got, err := hex.DecodeString(sig)
        if found && err == nil && hmac.Equal(got, expected) {
            ok = true // keep checking the other entries
        }
    }
    return ok
}

How do I read the raw body in a net/http handler?

On a server request, r.Body is always non-nil and the server closes it, so the handler only reads it: once, into a byte slice, with io.ReadAll. Wrap it in http.MaxBytesReader, which exists to limit incoming request bodies and returns a *MaxBytesError past the limit. Size the cap above 1 MiB, the largest run receipt Sume inlines, so a run delivery never hits it. A ServeMux pattern can match the method, so register it with http.HandleFunc("POST /hooks/sume", sumeWebhook); the handler also imports encoding/json, io, net/http, and os.

func sumeWebhook(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 2<<20)) // 2 MiB cap
    if err != nil { // for example a *http.MaxBytesError past the cap
        http.Error(w, "body too large", http.StatusRequestEntityTooLarge)
        return
    }
    secret := []byte(os.Getenv("SUME_COM_WEBHOOK_SIGNING_SECRET"))
    if !verifySume(body, r.Header.Get("X-Sume-Webhook-Timestamp"),
        r.Header.Get("X-Sume-Webhook-Signature"), secret) {
        http.Error(w, "bad signature", http.StatusUnauthorized)
        return
    }
    var event struct {
        Event     string `json:"event"`
        RequestID string `json:"request_id"` // dedupe key for run webhooks
        JobID     string `json:"job_id"`     // dedupe key for job webhooks
    }
    if err := json.Unmarshal(body, &event); err != nil { // the verified bytes
        http.Error(w, "bad json", http.StatusBadRequest)
        return
    }
    recordOnce(event.Event, event.RequestID, event.JobID, body) // your store or queue
    w.WriteHeader(http.StatusNoContent)
}

Which mistakes break a Go verifier?

Most broken or unsafe verifiers trace back to one of these:

  • Decoding r.Body with json.NewDecoder before verifying. Verify the slice from io.ReadAll, then json.Unmarshal that same slice.
  • Hashing re-marshaled JSON. A parsed and re-serialized object does not verify, because key order and whitespace are part of what Sume signed.
  • Comparing hex strings with ==, or bytes with bytes.Equal. Use hmac.Equal, as the Go docs advise for MACs.
  • A body cap below 1 MiB. A large receipt then fails the read, the handler answers non-2xx, and Sume retries until its attempts run out.
  • The header or the secret. Comparing the whole header fails every delivery during a rotation, and a mismatched secret shows in the x-sume-webhook-secret-fingerprint header; debugging webhook delivery covers both.

What should the handler do after it verifies?

Record the event, answer 204 inside Sume's 10-second attempt window, and hand slow work to a queue; the handler's struct already carries request_id and job_id, the dedupe keys for runs and jobs. Signed webhooks for Sume video runs covers the other delivery rules, and the PHP version runs the same check with hash_equals.

Sources

Related posts

More in Integrations

All Integrations posts

Written by Sume