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.

5 min readSume
All posts

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
end

What to read where

Each header and field has one job in the check.

Where each Sume webhook value comes from in a Sinatra route (Sume docs, read 2026-10-04)
ValueSource in SinatraUse
Raw bodyrequest.body.readSigned bytes; parse only after the check
TimestampHTTP_X_SUME_WEBHOOK_TIMESTAMPReplay window of 300 seconds
SignatureHTTP_X_SUME_WEBHOOK_SIGNATUREOne or more comma-separated sume-v1= entries
job_idParsed eventIdempotency key for your own write
eventParsed eventjob.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_compare returns false when the lengths differ, so a short or malformed entry never raises.
  • Return 204 quickly. Sume gives each attempt 10 seconds, and a slow route burns attempts.
  • A failed or canceled job arrives with status: "ERROR" and an error object, so branch on event before you read payload.artifacts.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume