Verify a Sume webhook in Ruby with OpenSSL, no Rack or Rails
A plain Ruby verifier for Sume's sume-v1 HMAC header: OpenSSL::HMAC, a constant-time compare that works on old Rubies, a 300 second window and a self-test.

Sume's SDK is TypeScript, so a Ruby service verifies webhooks with its own code. The scheme is short and fully documented: HMAC-SHA256 over <timestamp>.<raw_body>, sent as sume-v1=<hex> in x-sume-webhook-signature, with the timestamp in x-sume-webhook-timestamp. During a secret rotation the signature header can hold several comma-separated entries, and any one that matches is valid.
You do not need Rack's secure_compare or Rails' ActiveSupport::SecurityUtils to do this. This version uses only the openssl standard library and runs on old Rubies as well as new ones.
The verifier
Newer OpenSSL gems offer a fixed-length compare that raises on unequal lengths, and older ones lack it. The same? helper avoids both problems by hashing each side with SHA-256 first, so the byte arrays always have the same length, and then XOR-ing every byte instead of stopping at the first difference. The rest refuses an empty secret, a non-numeric timestamp and a delivery older or newer than 300 seconds.
require "openssl"
TOLERANCE = 300
def same?(a, b)
# Hash both sides so lengths always match, then XOR every byte.
x = OpenSSL::Digest::SHA256.digest(a).bytes
y = OpenSSL::Digest::SHA256.digest(b).bytes
x.zip(y).reduce(0) { |acc, (p, q)| acc | (p ^ q) }.zero?
end
def verify?(raw, headers, secret, now: Time.now.to_i)
return false if secret.to_s.empty?
ts = headers["x-sume-webhook-timestamp"].to_s
sig = headers["x-sume-webhook-signature"].to_s
return false unless ts.match?(/\A\d+\z/) && (now - ts.to_i).abs <= TOLERANCE
digest = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
want = "sume-v1=#{digest}"
sig.split(",").map(&:strip).map { |part| same?(part, want) }.any?
endTest it
Append this to the same file and run it with ruby. It signs a body with a test secret, then checks that the good signature passes and that an empty secret, an altered body, a short signature and a stale timestamp all fail. It prints ok when every assertion holds.
secret = "whsec_test"
raw = '{"event":"job.completed"}'
ts = Time.now.to_i.to_s
good = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
head = { "x-sume-webhook-timestamp" => ts, "x-sume-webhook-signature" => good }
raise "good signature rejected" unless verify?(raw, head, secret)
raise "empty secret accepted" if verify?(raw, head, "")
raise "tampered body accepted" if verify?(raw + " ", head, secret)
raise "short signature accepted" if verify?(raw, head.merge("x-sume-webhook-signature" => "sume-v1=00"), secret)
raise "stale accepted" if verify?(raw, head, secret, now: ts.to_i + 301)
puts "ok"Using it in a Rack or Sinatra handler
- Read the body with
request.body.readonce and keep the string. Do not parse JSON first and re-serialise it, since that changes the bytes. - Rack does not give you the original header names. They arrive as
HTTP_X_SUME_WEBHOOK_SIGNATUREin the env hash, so map them to the lower-case names the verifier expects. - Answer 401 when
verify?returns false and 2xx as soon as it returns true. Queue the real work. - Load the secret from an environment variable and fail at boot when it is blank, as the verifier would reject every delivery anyway.
What the checks cover
| Step | Reason |
|---|---|
| Raw body in the HMAC | The signature covers exact bytes |
| 300 second window | Rejects replay of a captured delivery |
| Any sume-v1 entry matches | Keeps both secrets valid during a rotation |
| Empty secret refused | An empty key would accept forged requests |
The full header list, including x-sume-webhook-secret-fingerprint, which tells you which secret signed a delivery, is in the webhook guide. Ruby's own reference for the HMAC class is here.
Sources
Related posts
More in Developers
- Run an OpenRouter video script on Sume: four changes (Python)
Sume's /v1/videos copies OpenRouter's video wire. Port a polling script by changing the base URL and model id, then dropping seed, size and provider options.
- Sume run webhook 3xx redirect: a failed attempt, not a delivery
Sume does not follow redirects on run webhooks, so a trailing-slash 301 fails every attempt. How to find it with a no-follow probe and register the final URL.
- reqwest timeout versus read_timeout for Sume polls and downloads
reqwest has no timeout by default. timeout() is a total deadline including the body; read_timeout() resets per read. Which to use for Sume status polls.
- Same narrator every week: pin the voice, language and speed
Keep one narrator across a weekly series on Sume TTS: pin avatar_handle with voice.id, language, speed and format in one config, with one key per episode.
Written by Sume