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.

4 min readSume
All posts

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?
end

Test 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.read once 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_SIGNATURE in 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

Sume webhook verification steps and why they exist, from the webhook guide (read 2026-10-03)
StepReason
Raw body in the HMACThe signature covers exact bytes
300 second windowRejects replay of a captured delivery
Any sume-v1 entry matchesKeeps both secrets valid during a rotation
Empty secret refusedAn 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

All Developers posts

Written by Sume