Clojure: Ring middleware that verifies a Sume webhook from raw bytes
Verify a Sume webhook in Clojure: slurp Ring's one-shot body as bytes, HMAC-SHA256 with javax.crypto, MessageDigest/isEqual, rotation entries, 300 s window.

To verify a Sume webhook in Clojure, read the Ring request body once as bytes, build an HMAC-SHA256 with javax.crypto.Mac over the timestamp, a dot, and those bytes, prefix the hex with sume-v1=, and compare each entry in x-sume-webhook-signature with MessageDigest/isEqual. Ring's :body is an InputStream that you can read only once, so the verification middleware must slurp the bytes, verify them, then hand the same bytes to your JSON parser. Reject an empty secret and any timestamp more than 300 seconds off.
The scheme is the one in Sume's Webhooks and Run webhooks docs (read 2026-10-11).
Why does the body need to be read first in Ring?
Middleware such as wrap-json-body consumes the stream and replaces it with parsed data. After that you only have a re-serialized map, and the bytes Sume signed are gone. Put your verifier ahead of any body parser, call (.readAllBytes (:body req)), and assoc the bytes onto the request under a key of your own, for example :raw-body. Parse JSON from that value afterward.
Use the signed bytes, not slurp into a String. A JSON body is almost always valid UTF-8, but bytes remove the question.
| Need | Sume rule | Clojure or JDK |
|---|---|---|
| Raw body | Verify before any JSON parse | (.readAllBytes (:body req)) |
| Timestamp | Within five minutes, header x-sume-webhook-timestamp | parse-long and System/currentTimeMillis |
| HMAC | SHA-256 over <ts>.<body> | Mac/getInstance "HmacSHA256" with two .update calls |
| Rotation | Several sume-v1= entries | str/split header #"," |
| Compare | Constant time | MessageDigest/isEqual |
What is the verify function?
It throws on a blank secret, returns false for a missing or stale timestamp, and doalls the comparisons so every entry is checked even after one matches.
(ns verify
(:require [clojure.string :as str])
(:import (javax.crypto Mac) (javax.crypto.spec SecretKeySpec)
(java.security MessageDigest)))
(defn- utf8 [^String s] (.getBytes s "UTF-8"))
(defn verify? [^String secret ^bytes body ts header]
(when (str/blank? secret) (throw (ex-info "empty signing secret" {})))
(let [t (parse-long (str ts))]
(boolean
(when (and t (<= (abs (- (quot (System/currentTimeMillis) 1000) t)) 300))
(let [mac (doto (Mac/getInstance "HmacSHA256")
(.init (SecretKeySpec. (utf8 secret) "HmacSHA256"))
(.update (utf8 (str t ".")))
(.update body))
want (utf8 (str "sume-v1=" (apply str (map #(format "%02x" (bit-and % 0xff))
(.doFinal mac)))))]
(some true? (doall (map #(MessageDigest/isEqual (utf8 (str/trim %)) want)
(str/split header #",")))))))))What does the handler return?
Return 401 when verify? is false and 200 when the event is stored. Sume treats any 2xx as success, does not follow redirects (a 3xx counts as a failed attempt), and waits 10 seconds per attempt. Job events dedupe on job_id; run events dedupe on request_id.
Run payloads can be null if the receipt exceeds 1 MiB. In that case error.code is payload_too_large and error.result_url tells you where to fetch the receipt, so check for nil before you destructure.
How do I test it without a tunnel?
Run the function from a REPL with a secret you pick, sign a body with the same Mac calls, and feed it a header such as sume-v1=00, sume-v1=<good>. That should return true, while sume-v1=00 alone returns false and an empty secret throws. I ran this check on Clojure 1.12 before publishing.
Then use Send test on /dashboard/webhooks. It posts a signed webhook.test body with no job_id, which is a handy way to exercise the middleware without spending credits.
Sources
Related posts
More in Developers
- Django view for Sume webhooks: verify request.body, then parse
A Django view that verifies a Sume webhook against request.body: csrf_exempt, 300 s replay window, empty-secret refusal, multi-signature header, 204.
- Eleven rows on Sume return MP3 only: use Sonic when you need WAV
Sume's eleven-* TTS Router ids return MP3 only, and three of the five accept just one format. WAV, avatar voices and word timings belong to the sonic ids.
- Eleven v4 seed and voice settings: what each Sume router id accepts
Which of stability, similarity_boost, style, speed and seed work on eleven-v4, v4-turbo, v3, multilingual-v2 and turbo-v2.5 in the Sume TTS Router, with ranges.
- ElevenLabs voice ids in the Sume TTS Router: list them, avoid 400s
Eleven v4 and other eleven-* ids run through the Sume TTS Router. How to list voices with family=eleven, which id shape each family takes, and what gets a 400.
Written by Sume