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.

5 min readSume
All posts

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.read once, into raw.
  • env["HTTP_X_SUME_WEBHOOK_TIMESTAMP"] and env["HTTP_X_SUME_WEBHOOK_SIGNATURE"] for the headers.
  • OpenSSL.fixed_length_secure_compare for 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.

Sume job webhook delivery rules (read 2026-10-07)
RuleValue
Eventsjob.completed, job.failed, job.canceled (terminal only)
SignatureHMAC-SHA256 over <timestamp>.<raw_body>, header sume-v1=<hex>
Replay windowReject timestamps outside about 5 minutes (300 s used here)
AttemptsUp to 10, a fixed 30 s apart by default, 10 s timeout each
AcknowledgeAny 2xx after you stored the event
Dedupe keyjob_id
Send testPOST /v1/webhooks/test-deliveries (account:write), body is webhook.test
RedeliverPOST /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

All Developers posts

Written by Sume