Gleam: verify a Sume webhook with gleam_crypto and secure_compare
Verify a Sume webhook in Gleam: crypto.hmac over timestamp.raw_body as a BitArray, lower-cased base16, secure_compare, rotation entries, and a 300 s window.

To verify a Sume webhook in Gleam, build a BitArray of the timestamp, a dot, and the raw body; sign it with crypto.hmac(msg, crypto.Sha256, key) from gleam_crypto; lower-case the bit_array.base16_encode output; prefix sume-v1=; and compare with crypto.secure_compare against every comma-separated entry of x-sume-webhook-signature. Pass the current Unix time in as an argument so the replay window is testable, and fail loudly on an empty secret.
The scheme comes from Sume's Webhooks and Run webhooks pages (read 2026-10-11).
Why does Gleam make this easy to get right?
Gleam has no exceptions for flow control, so the three bad-input cases (non-numeric timestamp, stale timestamp, mismatched signature) are plain False values from one case expression. The only panic is the empty secret, which is a deployment error and should stop the process rather than quietly reject every delivery.
base16_encode returns upper-case hex, while Sume sends lower-case, so string.lowercase is not optional. Forgetting it is the most common reason a first Gleam verifier fails on every request.
| Rule | Function |
|---|---|
Signed bytes are <timestamp>.<raw body> | bit_array.append(<<ts:utf8, ".":utf8>>, body) |
| HMAC-SHA256 with the workspace secret | crypto.hmac(msg, crypto.Sha256, <<secret:utf8>>) |
Lower-case hex with sume-v1= prefix | string.lowercase(bit_array.base16_encode(mac)) |
| Several entries while a secret rotates | string.split(header, ",") then string.trim |
| Constant-time comparison | crypto.secure_compare |
| Five-minute replay window | int.absolute_value(now - t) <= 300 |
What is the full function?
list.map runs every comparison before list.any looks at the results, so the response time does not reveal which header entry matched.
import gleam/bit_array
import gleam/crypto
import gleam/int
import gleam/list
import gleam/string
pub fn verify(secret: String, body: BitArray, ts: String, header: String, now: Int) -> Bool {
case secret, int.parse(ts) {
"", _ -> panic as "empty signing secret"
_, Error(_) -> False
_, Ok(t) ->
case int.absolute_value(now - t) <= 300 {
False -> False
True -> {
let msg = bit_array.append(<<ts:utf8, ".":utf8>>, body)
let mac = crypto.hmac(msg, crypto.Sha256, <<secret:utf8>>)
let want = "sume-v1=" <> string.lowercase(bit_array.base16_encode(mac))
header
|> string.split(",")
|> list.map(fn(e) {
crypto.secure_compare(<<string.trim(e):utf8>>, <<want:utf8>>)
})
|> list.any(fn(ok) { ok })
}
}
}
}Where does the raw body come from?
Read the request body as a BitArray and hand the same value to verify and then to your JSON decoder. Whatever web server you use, set its body-size limit above 1 MiB if you want whole run receipts; Sume sends payload: null plus a result_url for anything larger.
Respond 401 on False and 200 once the event is stored. Sume waits 10 seconds per attempt and tries up to 10 times, so a slow handler produces duplicates. Dedupe on job_id (job events) or request_id (run events).
How did I check it?
I created a project with gleam new, added gleam_crypto, signed a fake body with the same key, and called verify three times. A header with a stale entry followed by the good one returned True. The stale entry alone returned False. The good signature with now set 9,000 seconds later returned False.
Next, send a real signed delivery: use Send test on /dashboard/webhooks, or POST /v1/webhooks/test-deliveries with an account:write key. The webhook.test body has no job or run id, so do not write it to the table that you dedupe on.
Sources
Related posts
More in Developers
- Go Fiber v3: BodyRaw for Sume webhooks, and copy it before you queue
Sign-check a Sume webhook in Go Fiber with c.BodyRaw() and hmac.Equal, then copy the bytes before handing them to a goroutine, since Fiber reuses them.
- Grok Imagine Video 1.5 Lite on Vercel AI Gateway vs Sume /v1/videos
Vercel added Grok Imagine Video 1.5 Lite to AI Gateway on Oct 8. What generateVideo gives you, and the equivalent text-to-video call on Sume's /v1/videos.
- Grok Imagine Video 1.5 Lite batch API: what xAI documents
xAI's Lite page lists a $0.02 per second price, 10 requests per second, batch support and two regions. Sume jobs are async instead; here is how they differ.
- Image-to-video with sound by API: Sume ids that take a first frame
Kandinsky 6.0 has an image-to-audio-video mode. On Sume these ids take a first_frame image and return sound, and these do not. Request code included.
Written by Sume