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.

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:
| Step | Sume rule | Go |
|---|---|---|
| Raw body | Verify the raw bytes before any JSON parse | io.ReadAll(http.MaxBytesReader(w, r.Body, n)) |
| Headers | x-sume-webhook-timestamp and x-sume-webhook-signature | r.Header.Get(...), which is case-insensitive and returns "" when the header is missing |
| MAC | HMAC-SHA256 over <timestamp>.<raw_body> | hmac.New(sha256.New, secret), then Write and Sum(nil) |
| Signature | sume-v1=<hex>; during a rotation, one entry per live secret, comma-separated | strings.Split on commas, then hex.DecodeString on the part after sume-v1= |
| Compare | Constant time; accept any matching entry | hmac.Equal(got, expected) for every entry |
| Replay window | Reject timestamps outside it; five minutes is a reasonable default | Compare 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.Bodywithjson.NewDecoderbefore verifying. Verify the slice fromio.ReadAll, thenjson.Unmarshalthat 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 withbytes.Equal. Usehmac.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-fingerprintheader; 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
- Google ADK MCP tools: connect McpToolset to Sume's server
Add Sume's hosted MCP server to a Google ADK agent with McpToolset, an API-key header, and tool_filter, and make paid tools ask for confirmation.
- Google Sheets to video automation with Apps Script and Sume
Turn up to 100 sheet rows into Sume video runs with one Apps Script request, then poll the queue from a time-driven trigger and write URLs back.
- Gradio video generation app: Sume API key in a Space secret
Build a Gradio video generation app on the Sume API: the key stays in a Hugging Face Space secret, a generator polls the job, and gr.Video plays it.
- Inngest wait for event: resume when a Sume video run ends
Start a Sume run in step.run, turn its webhook into an Inngest event with a transform, then step.waitForEvent on the run id with a 2h timeout.
Written by Sume