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.
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
| Guard | Why |
|---|---|
| Empty secret returns false | An unset environment variable must never validate anything |
| Integer timestamp or false | Rejects a missing or malformed x-sume-webhook-timestamp |
| Five-minute window | Docs call five minutes a reasonable replay tolerance |
| Any entry may match | During secret rotation the header carries sume-v1=<new>,sume-v1=<previous> |
| Length check, then fixed-length compare | Constant-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
- Slow down a TTS voiceover: speed 0.6 to 1.5, volume, emotion
Sume TTS accepts generation_config with speed 0.6 to 1.5, volume 0.5 to 2 and a free-text emotion guide. How to retime a voiceover to a video.
- Store the Sume artifact, not a provider link: what to persist per job
A completed Sume job returns artifacts with id, url, type and content_type. Persist those plus the job id and idempotency key, not provider links.
- stream: true on the Sume Image API returns 400. What to show instead
Sume image models have no SSE streaming: stream true returns 400 streaming_not_supported. Use async mode and job events for stages, or a webhook for the end.
- Sume API 401 on the dev host: keys only work on their own host
A Sume key works only on the host it was created for. A key from api.sume.com gets 401 on api.dev.sume.com and the reverse. Match key, host, and env var.
Written by Sume