Ruby verifier for Sume avatar job webhooks (HMAC SHA 256)

A Ruby method that verifies Sume's x-sume-webhook-signature: HMAC SHA 256 over timestamp.body, any sume-v1 entry, five-minute window, empty secret refused.

5 min readSume
All posts

To verify a Sume webhook in Ruby, compute OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw_body}"), prefix it with sume-v1=, and accept the delivery if any comma-separated entry in x-sume-webhook-signature matches in constant time. The method below also refuses an empty secret and a timestamp outside five minutes.

The scheme is from Webhooks, read 2026-10-10. The code was run with Ruby 4.0.7 against the three cases at the bottom.

The verifier

Pass the raw request body, not parsed JSON: re-serializing changes bytes and breaks the signature.

require "openssl"
def verify_sume(raw_body, timestamp, header, secret, tolerance = 300)
  return false if secret.to_s.empty?
  ts = Integer(timestamp, exception: false)
  return false if ts.nil? || (Time.now.to_i - ts).abs > tolerance
  digest = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw_body}")
  expected = "sume-v1=#{digest}"
  header.to_s.split(",").map(&:strip).any? do |entry|
    entry.bytesize == expected.bytesize && OpenSSL.fixed_length_secure_compare(entry, expected)
  end
end

secret = "whsec_test"
body = '{"event":"job.completed"}'
ts = Time.now.to_i.to_s
sig = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{body}")
p verify_sume(body, ts, "sume-v1=bad,#{sig}", secret)
p verify_sume(body, ts, sig, "")
p verify_sume(body + " ", ts, sig, secret)

What each guard does

Guards in the verifier (per docs.sume.com Webhooks, read 2026-10-10)
GuardWhy
Empty secret returns falseAn unset environment variable must never validate anything
Integer timestamp or falseRejects a missing or malformed x-sume-webhook-timestamp
Five-minute windowDocs call five minutes a reasonable replay tolerance
Any entry may matchDuring secret rotation the header carries sume-v1=<new>,sume-v1=<previous>
Length check, then fixed-length compareConstant-time compare needs equal-length strings

Wiring it in

In Rack, Rails or Sinatra, read request.body.read once and pass it along with x-sume-webhook-timestamp (HTTP_X_SUME_WEBHOOK_TIMESTAMP in the env) and x-sume-webhook-signature. Return 401 on false, and 200 quickly on success, doing the real work afterward, since each delivery has a 10-second timeout.

The secret is your workspace's signing secret, read from the dashboard Webhooks tab or GET /v1/webhooks/signing-secret with an account:read key. Keep it in an environment variable and never log it.

After verifying

Parse the JSON, read job_id, and upsert by it. A job can be delivered more than once (retries, or a Redeliver), so the handler must be idempotent. For an avatar video, a job.completed event is your cue to fetch the result from the result URL.

Test it before you ship it

The three lines at the end of the sample are the minimum test set: a good signature with a stale entry first in the header passes, an empty secret fails, and a body altered by one character fails. Add a fourth for a timestamp ten minutes old, which should fail.

Then use Send test from the dashboard to confirm that the same method passes a real signed delivery, which proves your framework hands the verifier the unmodified raw body.

Mistakes this avoids

Comparing with == leaks timing, and comparing strings of different lengths with a fixed-length function raises, which is why the method checks sizes first. Parsing JSON before verifying changes the bytes. Reading the body twice in a framework that streams it returns an empty string the second time, which would make every signature fail.

Last, do not skip the timestamp: a valid old delivery replayed by an attacker verifies fine without it.

Operational notes

Respond within the 10-second timeout and let a background job do the heavy work, because a slow handler triggers retries. Sume makes up to 10 attempts about 30 seconds apart, so a handler that fails for a minute will see the same event again. Log the fingerprint header on failures: it tells you at a glance whether the deployed secret differs from the dashboard one.

Using it with job events

Job webhooks deliver exactly three terminal events: completed, failed and canceled. A failed or canceled delivery uses status: "ERROR" and carries an error object, so branch on the event before you try to fetch a result. Only a completed job has one.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume