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.

5 min readSume
All posts

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.

Ring and JDK calls for the Sume signature, checked against Sume's Webhooks page (read 2026-10-11) and run on Clojure 1.12.
NeedSume ruleClojure or JDK
Raw bodyVerify before any JSON parse(.readAllBytes (:body req))
TimestampWithin five minutes, header x-sume-webhook-timestampparse-long and System/currentTimeMillis
HMACSHA-256 over <ts>.<body>Mac/getInstance "HmacSHA256" with two .update calls
RotationSeveral sume-v1= entriesstr/split header #","
CompareConstant timeMessageDigest/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

All Developers posts

Written by Sume