Go webhook handler: verify the Sume sume-v1 signature
A stdlib Go verifier for x-sume-webhook-signature: HMAC-SHA256 over timestamp.raw_body, five-minute window, constant-time compare, empty secret refused.

Short answer
A Go receiver needs four steps: read the raw body once, refuse an empty secret, check the timestamp is within five minutes, then compare sume-v1=<hex> against an HMAC-SHA256 of <timestamp>.<raw_body> with hmac.Equal. The scheme is documented on Sume's webhooks page, and the same verifier covers job webhooks and run webhooks.
Go 1.27's release notes add a Server.MaxHeaderValueCount field for HTTP servers (read 2026-10-03). It is not part of signature checking, but a public webhook endpoint is exactly the kind of server where you want a ceiling on header values, so it is worth setting when you upgrade.
The signature contract
Sume signs the raw JSON body. Parsing and re-serializing it first changes the bytes and breaks the match, so read the request body into a slice and verify that slice before you decode anything.
| Header | Value |
|---|---|
| x-sume-webhook-timestamp | unix seconds, part of the signed string |
| x-sume-webhook-signature | sume-v1=<hex>; during a secret rotation one entry per live secret, newest first, comma separated |
| x-sume-webhook-secret-fingerprint | identifies which secret signed; compare fingerprints instead of sending the secret |
A verifier you can paste
The function below returns false for an empty secret, a non-numeric or stale timestamp, and any header with no matching entry. It loops over every comma-separated entry so a rotation window accepts either signature. The main function signs a body itself and checks the three outcomes.
package main
import ("crypto/hmac"; "crypto/sha256"; "encoding/hex"; "fmt"; "math"; "strconv"; "strings"; "time")
func sign(secret, ts string, body []byte) string {
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(ts + "."))
m.Write(body)
return "sume-v1=" + hex.EncodeToString(m.Sum(nil))
}
func Verify(secret, ts, header string, body []byte) bool {
n, err := strconv.ParseInt(ts, 10, 64)
if secret == "" || err != nil || math.Abs(float64(time.Now().Unix()-n)) > 300 {
return false
}
want, ok := sign(secret, ts, body), false
for _, e := range strings.Split(header, ",") {
if hmac.Equal([]byte(strings.TrimSpace(e)), []byte(want)) { ok = true }
}
return ok
}
func main() {
b := []byte(`{"event":"job.completed"}`)
ts := strconv.FormatInt(time.Now().Unix(), 10)
good := "sume-v1=old," + sign("s3cret", ts, b)
fmt.Println(Verify("s3cret", ts, good, b), Verify("", ts, good, b), Verify("s3cret", "1", good, b))
}Wiring it into net/http
In the handler, read the body with io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)), call Verify(secret, r.Header.Get("x-sume-webhook-timestamp"), r.Header.Get("x-sume-webhook-signature"), body), and answer 401 when it returns false. Store the event durably, then return any 2xx; Sume treats network errors and non-2xx answers as failures and retries.
Delivery behavior to design for: up to 10 attempts in total, a fixed delay between attempts (30 seconds by default), and a 10 second timeout per attempt. Use job_id as your idempotency key, because a manual redeliver re-sends the real terminal event with a fresh timestamp and signature.
What Sume does and does not do
Sume delivers terminal job events only: job.completed, job.failed and job.canceled. There are no progress webhooks. Ten refused attempts leave a failed delivery and a job that still reached its real state, so keep status_url polling available as the recovery path.
Run webhooks (format.run.terminal, agent.run.terminal) use the same signature and secret but a different payload; the outcome is in status and payload.status, not in the event name.
Sources
Related posts
More in Developers
- Google Images formats and filenames for Sume output
Google Search supports BMP, GIF, JPEG, PNG, WebP, SVG and AVIF in img src, and wants short filenames and real alt text. Rename Sume downloads first.
- Google Play app icon: 512x512 32-bit PNG with alpha, 1024 KB
Play wants a 512 x 512, 32-bit PNG with alpha, at most 1024 KB. Get a transparent square from ChatGPT Image 2.5 on Sume, then resize and check the size.
- Google Play screenshots: 9:16 at 1080x1920, side limit 2x
Play screenshots need 320 to 3840 px and a long side at most twice the short side. Four 1080 x 1920 9:16 shots also meet the promo eligibility note on the page.
- Goose 1.52 recipe consent before extensions: a Sume MCP recipe
Goose 1.52 asks for recipe consent before session/new spawns extensions, and caps recipe size. What to put in a recipe that uses Sume's MCP server.
Written by Sume