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.

5 min readSume
All posts

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.

Gleam functions mapped to the Sume signature rules in Sume's Webhooks page (read 2026-10-11); code run with Gleam 1.18 and gleam_crypto 1.6+.
RuleFunction
Signed bytes are <timestamp>.<raw body>bit_array.append(<<ts:utf8, ".":utf8>>, body)
HMAC-SHA256 with the workspace secretcrypto.hmac(msg, crypto.Sha256, <<secret:utf8>>)
Lower-case hex with sume-v1= prefixstring.lowercase(bit_array.base16_encode(mac))
Several entries while a secret rotatesstring.split(header, ",") then string.trim
Constant-time comparisoncrypto.secure_compare
Five-minute replay windowint.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

All Developers posts

Written by Sume