Roda: verify a Sume webhook with r.body.read in config.ru
A Roda app in one config.ru that verifies a Sume webhook with OpenSSL HMAC and a length-checked secure compare. Aborts on an empty secret. Tested on Ruby 4.0.

A Roda route verifies a Sume webhook by reading r.body.read once, computing an HMAC-SHA256 over "#{timestamp}.#{raw}" and comparing it to each sume-v1= entry in the signature header. The config.ru below ran under Ruby 4.0.7 with Roda 3.108 and rackup. It aborts at boot when the secret is empty.
Read the raw bytes in Roda
Roda does not parse the body on its own, so there is nothing to undo. r.body is the Rack input stream, and r.body.read returns the raw string. Read it once into a local variable and pass that variable to both the verifier and the JSON parser.
Rack headers arrive in the environment hash with an HTTP_ prefix, upper-case and with underscores. The header x-sume-webhook-signature is therefore env["HTTP_X_SUME_WEBHOOK_SIGNATURE"].
r.body.readonce, intoraw.env["HTTP_X_SUME_WEBHOOK_TIMESTAMP"]andenv["HTTP_X_SUME_WEBHOOK_SIGNATURE"]for the headers.OpenSSL.fixed_length_secure_comparefor the match, after a byte-size check.
The receiver
OpenSSL.fixed_length_secure_compare raises when its arguments differ in length, which is why the loop compares bytesize first. A header entry that is too short is just a mismatch, never an exception. Install the gems with gem install roda rackup webrick, then run rackup -p 8000.
require "roda"
require "json"
require "openssl"
SECRET = ENV.fetch("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
abort "SUME_COM_WEBHOOK_SIGNING_SECRET is empty" if SECRET.empty?
SEEN = {}
class App < Roda
def verify(raw, ts, header)
return false unless ts =~ /\A\d+\z/ && (Time.now.to_i - ts.to_i).abs <= 300
want = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", SECRET, "#{ts}.#{raw}")
header.to_s.split(",").map(&:strip).any? do |e|
e.bytesize == want.bytesize && OpenSSL.fixed_length_secure_compare(e, want)
end
end
route do |r|
r.post "hooks", "sume" do
raw = r.body.read
unless verify(raw, env["HTTP_X_SUME_WEBHOOK_TIMESTAMP"], env["HTTP_X_SUME_WEBHOOK_SIGNATURE"])
response.status = 401
next "bad signature"
end
id = JSON.parse(raw)["job_id"]
puts "new job #{id}" if id && !SEEN.key?(id)
SEEN[id] = true if id
"ok"
end
end
end
run App.freeze.app
What the check has to do
The rules come from the webhooks guide. Sume signs the raw JSON body, so the receiver hashes the bytes it received and never a re-serialised object. During a secret rotation the signature header can hold several comma-separated entries, newest first, and a delivery is good when any one matches.
The secret comes from SUME_COM_WEBHOOK_SIGNING_SECRET. The program above stops at start-up when the variable is empty, so a missing secret cannot turn into an endpoint that accepts everything.
| Rule | Value |
|---|---|
| Events | job.completed, job.failed, job.canceled (terminal only) |
| Signature | HMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex> |
| Replay window | Reject timestamps outside about 5 minutes (300 s used here) |
| Attempts | Up to 10, a fixed 30 s apart by default, 10 s timeout each |
| Acknowledge | Any 2xx after you stored the event |
| Dedupe key | job_id |
| Send test | POST /v1/webhooks/test-deliveries (account:write), body is webhook.test |
| Redeliver | POST /v1/jobs/{job_id}/webhook/redeliver (jobs:write), fresh timestamp and signature |
The webhook.test event has no job_id
The Send test action in the dashboard posts a webhook.test body that has no job_id. In Ruby JSON.parse(raw)["job_id"] is simply nil for it, so the handler guards with if id before it touches the seen hash and the test returns "ok".
If a real job webhook was lost, POST /v1/jobs/{job_id}/webhook/redeliver with a jobs:write key sends the terminal event again with a fresh timestamp and signature.
Prove it with a signed request
The SEEN hash is process memory, and a second Puma worker would not share it. In production use a table with a unique index on job_id.
The next block is a bash script with openssl and curl that signs one body three ways and prints the status of each answer. Start the receiver with SUME_COM_WEBHOOK_SIGNING_SECRET=whsec_test rackup -p 8000 first. The output should be 200, then 401, then 401.
#!/usr/bin/env bash
set -eu
URL="${1:-http://127.0.0.1:8000/hooks/sume}"
BODY='{"event":"job.completed","request_id":"job_demo","job_id":"job_demo","status":"OK","payload":{"artifacts":[]}}'
sign() { # $1 = timestamp, $2 = secret
printf '%s.%s' "$1" "$BODY" | openssl dgst -sha256 -hmac "$2" -hex | sed 's/^.* //'
}
send() { # $1 = timestamp, $2 = secret
curl -s -o /dev/null -w '%{http_code}\n' -X POST "$URL" \
-H 'Content-Type: application/json' \
-H "x-sume-webhook-timestamp: $1" \
-H "x-sume-webhook-signature: sume-v1=$(sign "$1" "$2")" \
--data-binary "$BODY"
}
NOW=$(date +%s)
echo "right secret: $(send "$NOW" "$SUME_COM_WEBHOOK_SIGNING_SECRET")"
echo "wrong secret: $(send "$NOW" "${SUME_COM_WEBHOOK_SIGNING_SECRET}x")"
echo "stale timestamp: $(send "$((NOW - 900))" "$SUME_COM_WEBHOOK_SIGNING_SECRET")"
Sources
Related posts
More in Developers
- Rotate the Sume webhook signing secret without dropping a delivery
Upgrade the verifier first, rotate with POST /v1/webhooks/signing-secret/rotate, deploy the new secret inside the 24-hour two-signature window, then confirm it.
- Route Sume run webhooks by event: format, action and agent terminal
Run webhooks use one terminal event per family: action.run.terminal, format.run.terminal, agent.run.terminal. Route on event, then branch on outcome.
- Ruby Net::HTTP: create a Sume bulk queue and poll to an exit code
A 30-line Ruby script with only the standard library: create a Sume bulk queue from items.json, back off the poll, skip 429 and 503, and exit 1 on failed items.
- Ruby Net::HTTP: POST /v1/videos with callback_url, for a Sora port
A 15-line Net::HTTP submit for /v1/videos that sends an HTTPS callback_url and an Idempotency-Key, checks for 202, and stores the id before any callback.
Written by Sume