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.

5 min readSume
All posts

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.

Sume webhook delivery headers (as of 2026-10-03)
HeaderValue
x-sume-webhook-timestampunix seconds, part of the signed string
x-sume-webhook-signaturesume-v1=<hex>; during a secret rotation one entry per live secret, newest first, comma separated
x-sume-webhook-secret-fingerprintidentifies 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

All Developers posts

Written by Sume