One Sume webhook signature, three languages: a shared test vector
A fixed secret, timestamp and body that must sign to the same sume-v1 value in Python, Node and Go. Use it to test a verifier in any language you add.

Sume signs a webhook with HMAC-SHA256 over the string <timestamp>.<raw_body> and sends the result as x-sume-webhook-signature: sume-v1=<hex>, with the timestamp in x-sume-webhook-timestamp. If you port that to a new language, the quickest correctness check is a fixed input that every implementation must turn into the same output. A test vector is exactly that.
The values below are made up for testing. The secret is not a real Sume signing secret; real ones come from GET /v1/webhooks/signing-secret.
| Input | Value |
|---|---|
| Secret | whsec_test_123 |
| Timestamp (seconds) | 1790000000 |
| Raw body | {"event":"job.completed","job_id":"job_123"} |
| String signed | 1790000000.{"event":"job.completed","job_id":"job_123"} |
| Expected header | sume-v1=5ba7a215439d4b856d34fa60b0bd467778efa0d98eca14f659fcb5e74b1ca1f1 |
Python
import hashlib, hmac
def sign(secret, ts, body):
if not secret:
raise ValueError("empty secret")
msg = f"{ts}.".encode() + body
return "sume-v1=" + hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
body = b'{"event":"job.completed","job_id":"job_123"}'
print(sign("whsec_test_123", 1790000000, body))Node
import { createHmac } from "node:crypto";
function sign(secret, ts, body) {
if (!secret) throw new Error("empty secret");
return "sume-v1=" + createHmac("sha256", secret).update(`${ts}.`).update(body).digest("hex");
}
const body = Buffer.from('{"event":"job.completed","job_id":"job_123"}');
console.log(sign("whsec_test_123", 1790000000, body));Go
All three print the same line. So does the shell check, which prints the bare hex without the sume-v1= prefix: printf '%s' '1790000000.{"event":"job.completed","job_id":"job_123"}' | openssl dgst -sha256 -hmac whsec_test_123.
package main
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
)
func sign(secret string, ts int, body []byte) (string, error) {
if secret == "" {
return "", errors.New("empty secret")
}
m := hmac.New(sha256.New, []byte(secret))
m.Write([]byte(fmt.Sprintf("%d.", ts)))
m.Write(body)
return "sume-v1=" + hex.EncodeToString(m.Sum(nil)), nil
}
func main() {
s, _ := sign("whsec_test_123", 1790000000, []byte(`{"event":"job.completed","job_id":"job_123"}`))
fmt.Println(s)
}Using it
Drop the table into your verifier's unit tests: feed it the timestamp, the raw bytes and the header, and expect acceptance; flip one body byte and expect rejection. Each sample here refuses an empty secret, which is the other case worth a test. A real verifier also enforces the 300-second timestamp tolerance and accepts any of the comma-separated entries during secret rotation; see the related posts for those.
The vector deliberately uses an old timestamp, so a verifier with a recency check needs its clock injected to pass it.
Sources
Related posts
More in Developers
- Agents SDK cache_tools_list: stale tools after you grant Sume Write
With cache_tools_list on, an OpenAI Agents SDK MCP server can keep an old tool list. After a Sume Write grant, call invalidate_tools_cache() to see paid tools.
- X-OpenRouter-Idempotency-Key vs Sume webhook dedupe on job_id
OpenRouter's webhook dedupe key is job_id plus status. Sume says dedupe on job_id. A SQLite sample builds a job_id plus event key that skips replays.
- Pandas DataFrame to a Sume bulk queue in 100-row chunks
Turn a product DataFrame into Sume Format bulk queues: one item per row, 100 rows per queue, a stable key per chunk, and SKU order saved beside each queue id.
- Perl HTTP::Tiny: POST /v1/images on Sume with core modules only
Call Sume's image API from Perl with HTTP::Tiny and JSON::PP, both core modules; handles 200, 202 and errors, with a 40-second timeout.
Written by Sume