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
- 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.
- got maxRetryAfter and 429: rate_limited vs queue_full on Sume
got retries 429 and honors Retry-After up to maxRetryAfter. Sume sends 429 for rate_limited and for queue_full, which need different waits. Here is the split.
- got does not retry POST by default: enable it safely with Sume
got retries GET, PUT and DELETE but not POST. To retry a Sume paid submit, add POST to retry.methods and send one Idempotency-Key reused on every attempt.
- GPT-6.1 Sol rate limits (Tier 1: 500 RPM) vs a Sume bulk run window
OpenAI lists GPT-6.1 Sol limits from 500 RPM at Tier 1 to 15,000 RPM at Tier 5. A Sume bulk run uses a concurrency window of 1-16 and 100 items.
Written by Sume