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.

5 min readSume
All posts

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.

Test vector, computed with Python, Node 22.14 and Go 1.27.1 (read 2026-10-07)
InputValue
Secretwhsec_test_123
Timestamp (seconds)1790000000
Raw body{"event":"job.completed","job_id":"job_123"}
String signed1790000000.{"event":"job.completed","job_id":"job_123"}
Expected headersume-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

All Developers posts

Written by Sume