Sinatra: request.body.read and secure_compare for Sume webhooks
A Sinatra route that reads the raw Rack input, rewinds it, checks the sume-v1 HMAC with secure_compare and aborts on an empty signing secret.

In Sinatra, read the body once with request.body.read, rewind it with request.body.rewind, and compute OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw}"). Compare it with every sume-v1= entry in the x-sume-webhook-signature header using Rack::Utils.secure_compare, and call halt 401 when none match. Parse JSON only after that.
Rack exposes headers in the request environment with an HTTP_ prefix, so x-sume-webhook-timestamp is HTTP_X_SUME_WEBHOOK_TIMESTAMP. Sume's webhook docs define the scheme, the five-minute replay window and the rotation format.
The route
The script aborts at boot when SUME_COM_WEBHOOK_SIGNING_SECRET is empty. It checks the timestamp is all digits before it does arithmetic on it, because "".to_i is 0 and a missing header should fail rather than compare against the epoch.
require "json"
require "openssl"
require "sinatra"
SECRET = ENV.fetch("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
abort "SUME_COM_WEBHOOK_SIGNING_SECRET is empty" if SECRET.empty?
def valid?(raw, ts, header)
return false unless ts.to_s.match?(/\A\d+\z/)
return false if (Time.now.to_i - ts.to_i).abs > 300
mac = OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{raw}")
want = "sume-v1=#{mac}"
header.to_s.split(",").any? { |e| Rack::Utils.secure_compare(e.strip, want) }
end
post "/sume" do
raw = request.body.read
request.body.rewind
ts = request.env["HTTP_X_SUME_WEBHOOK_TIMESTAMP"]
sig = request.env["HTTP_X_SUME_WEBHOOK_SIGNATURE"]
halt 401 unless valid?(raw, ts, sig)
event = JSON.parse(raw)
puts "job #{event["job_id"]} #{event["event"]}" if event["job_id"]
status 204
endWhat to read where
Each header and field has one job in the check.
| Value | Source in Sinatra | Use |
|---|---|---|
| Raw body | request.body.read | Signed bytes; parse only after the check |
| Timestamp | HTTP_X_SUME_WEBHOOK_TIMESTAMP | Replay window of 300 seconds |
| Signature | HTTP_X_SUME_WEBHOOK_SIGNATURE | One or more comma-separated sume-v1= entries |
| job_id | Parsed event | Idempotency key for your own write |
| event | Parsed event | job.completed, job.failed or job.canceled |
Try it without a paid job
Run it with ruby app.rb after setting the secret, and point Sume's dashboard Send test at the public URL. The test posts a signed webhook.test body with no job_id, so you can watch the verification pass without a real render. It never replays a real job.
After the check, the real work is dedupe. Write job_id to a table with a unique key before you do anything else, because delivery makes up to 10 attempts and Redeliver can send the same terminal event again.
Caveats
secure_comparereturns false when the lengths differ, so a short or malformed entry never raises.- Return
204quickly. Sume gives each attempt 10 seconds, and a slow route burns attempts. - A failed or canceled job arrives with
status: "ERROR"and anerrorobject, so branch oneventbefore you readpayload.artifacts.
Sources
Related posts
More in Developers
- Slow OAuth consent and MCP timeouts: Sume's Write toggle step
MCP Python SDK v2.3.0 stops counting interactive OAuth logins against request timeouts. Here is the consent step with Sume's Write toggle that benefits.
- Snap a requested video length to supported durations in Python
Veo 3.1 accepts only 4, 6 or 8 seconds. A short Python helper snaps any requested length to a model's supported_durations list from GET /v1/videos/models.
- Sora ended in two steps: app in April, API on September 24
OpenAI's docs say the Sora API shut down Sep 24, 2026 with no direct replacement. A help-center snippet dates the app and web end at Apr 26. What to inventory.
- Downloading the mp4 inside the webhook? Sume's 10 s limit
A callback handler that fetches the video before replying will time out. Sume allows each webhook delivery 10 seconds and 10 attempts: store the id, return 2xx.
Written by Sume