Go httptest: a four-case table test for a Sume webhook handler
Sign a fixture body with sume-v1 HMAC, then assert 204 for a valid call and 401 for a stale timestamp, an empty server secret and a wrong secret.

Build the signature in the test with the same recipe Sume uses, HMAC-SHA256 over {timestamp}.{raw_body} prefixed sume-v1=, then drive your handler through httptest for each case you must reject. Sume's webhook docs set the replay window at five minutes and say to refuse a delivery that fails the check, so four cases cover the contract: valid, stale, empty secret and wrong secret.
The four cases
The empty-secret case matters most. An unset environment variable gives a zero-length key, and HMAC with an empty key still returns a valid-looking digest that an attacker can compute, so the handler must refuse before it hashes.
| Case | Signed with | Server secret | Expect |
|---|---|---|---|
| valid | s3cret | s3cret | 204 |
| stale timestamp (600 s old) | s3cret | s3cret | 401 |
| empty secret | s3cret | (empty) | 401 |
| wrong secret | other | s3cret | 401 |
The test
It assumes a package wh with Handler(secret string) http.HandlerFunc, the receiver from the linked Go verifier post. The test sets x-sume-webhook-timestamp and x-sume-webhook-signature exactly as a delivery does and passes the body unchanged, since a re-serialized body does not verify.
package wh
import ("crypto/hmac"; "crypto/sha256"; "encoding/hex"; "net/http/httptest"; "strconv"; "strings"; "testing"; "time")
func sign(secret, body string, ts int64) (string, string) {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(strconv.FormatInt(ts, 10) + "." + body))
return strconv.FormatInt(ts, 10), "sume-v1=" + hex.EncodeToString(mac.Sum(nil))
}
func TestHandler(t *testing.T) {
body, now := `{"event":"job.completed","job_id":"job_1"}`, time.Now().Unix()
for _, c := range []struct{ name, secret, signWith string; age int64; want int }{
{"valid", "s3cret", "s3cret", 0, 204},
{"stale timestamp", "s3cret", "s3cret", 600, 401},
{"empty secret", "", "s3cret", 0, 401},
{"wrong secret", "s3cret", "other", 0, 401},
} {
ts, sig := sign(c.signWith, body, now-c.age)
r := httptest.NewRequest("POST", "/hook", strings.NewReader(body))
r.Header.Set("x-sume-webhook-timestamp", ts)
r.Header.Set("x-sume-webhook-signature", sig)
w := httptest.NewRecorder()
Handler(c.secret).ServeHTTP(w, r)
if w.Code != c.want {
t.Errorf("%s: got %d, want %d", c.name, w.Code, c.want)
}
}
}What to add
During a secret rotation the signature header holds one entry per live secret, newest first, separated by commas, and a delivery is valid if any entry matches. Add a fifth case with sume-v1=00, in front of the good signature to prove your handler accepts it.
This test never contacts Sume. To check your public HTTPS URL end to end, the API has a test-delivery endpoint, POST /v1/webhooks/test-deliveries.
Sources
Related posts
More in Developers
- Go: poll a Sume job and stop on any terminal state, even a new one
A Go status loop that names completed, failed and canceled, stops on terminal=true for anything else, and never sleeps less than next_poll_after_seconds.
- gpt-image-1.5 is removed Dec 1: a 55-day cutover calendar
OpenAI removes gpt-image-1.5, gpt-image-1-mini and chatgpt-image-latest on December 1, 2026. A week-by-week plan from October 7 to move to GPT Image 2.5.
- GPT Image 2.5 banner: 3840x1280 passes the 3:1 rule
3840x1280 is exactly 3:1, both edges are multiples of 16 and it is 4,915,200 pixels, so Sume accepts it on GPT Image 2.5. The four checks shown.
- Handle every Sume API error with one switch on next_action
Sume errors share one envelope. Branch on next_action, retryable and retry_after_seconds, and your client handles new codes without a code change. JS sample.
Written by Sume