Rails webhooks: verify an HMAC signature in a controller
Read request.raw_post, skip CSRF for that action only, check OpenSSL::HMAC.hexdigest against each sume-v1 entry with secure_compare, then head 204.

To receive webhooks in Rails, route the POST to a controller action that reads request.raw_post, the exact body bytes, before anything parses it, and skip CSRF protection for that action only. Check the signature, answer head :no_content, and hand slow work to a background job. For Sume's webhooks, compute OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw}") and compare sume-v1= plus that digest with each comma-separated entry of the signature header, using ActiveSupport::SecurityUtils.secure_compare.
Rails facts come from the Rails 8.1.4 API docs for ActionDispatch::Request, RequestForgeryProtection, SecurityUtils and Head, and from the Rails guides on API-only applications and Active Job; Ruby's OpenSSL::HMAC docs cover the digest. Sume facts come from Run webhooks, Webhooks and Verifying webhooks. All were read on 2026-09-28. Sume publishes no Ruby gem: its SDK is TypeScript, and its docs spell out the scheme for receivers in other languages. The PHP version is in PHP webhook signature verification.
How does Sume's signature map to Ruby and Rails?
Sume signs <timestamp>.<raw_body> with HMAC-SHA256 and sends sume-v1=<hex_signature> in x-sume-webhook-signature, with the timestamp in x-sume-webhook-timestamp. During a signing-secret rotation, the header carries one comma-separated entry per live secret, and a delivery is valid when any entry matches. Each step has a Ruby or Rails call:
secure_compare is Rails' secure comparison for strings of variable length. A timing attack can't discern the content it compares, only the length, and here the length is fixed anyway.
| Step | Sume rule | Ruby or Rails |
|---|---|---|
| Raw body | Verify the raw bytes before any JSON parse | request.raw_post |
| Headers | x-sume-webhook-timestamp, x-sume-webhook-signature | request.headers["X-Sume-Webhook-Signature"] |
| Digest | HMAC-SHA256 over <timestamp>.<raw_body>, hex-encoded | OpenSSL::HMAC.hexdigest("SHA256", secret, data) |
| Compare | Constant time; accept any sume-v1= entry | secure_compare against each entry of header.split(",") |
| Replay window | Reject timestamps outside it; five minutes is a reasonable default | (Time.now.to_i - ts.to_i).abs > 300 |
How do I verify the webhook in a Rails controller?
The action verifies the raw body first, parses that same string, enqueues the event, and answers 204. It checks every entry, and it refuses an empty secret, as Sume's TypeScript verifier does in current code, so an unset variable can't turn into an HMAC key anyone could compute.
# config/routes.rb: post "/hooks/sume", to: "sume_webhooks#create"
class SumeWebhooksController < ApplicationController
skip_forgery_protection only: :create # a webhook sender has no CSRF token
def create
raw = request.raw_post # the exact bytes Sume signed
return head :unauthorized unless sume_signature_valid?(raw)
SumeWebhookJob.perform_later(JSON.parse(raw)) # dedupe and work in the job
head :no_content
end
private
def sume_signature_valid?(raw)
secret = ENV.fetch("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
ts = request.headers["X-Sume-Webhook-Timestamp"].to_s
header = request.headers["X-Sume-Webhook-Signature"].to_s
return false if secret.empty? || !ts.match?(/\A\d+\z/) || (Time.now.to_i - ts.to_i).abs > 300
expected = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
header.split(",").map(&:strip).reduce(false) do |ok, entry|
ActiveSupport::SecurityUtils.secure_compare(entry, expected) || ok # check every entry
end
end
endWhy skip CSRF protection, and only for this action?
Rails' forgery protection doesn't check GET and HEAD requests, but a webhook arrives as a POST, and its sender has no authenticity token to send. When default_protect_from_forgery is true, Rails protects with with: :exception, which raises ActionController::InvalidAuthenticityToken. skip_forgery_protection wraps skip_before_action :verify_authenticity_token, and only: limits the skip to one action, so the rest of the controller stays protected. The HMAC check takes over as proof that the request came from Sume.
In an API-only app, whose controllers inherit from ActionController::API, leave the skip_forgery_protection line out: there is nothing to skip, because forgery protection isn't among the modules the Rails guide lists for API-only controllers.
Why does every signature fail?
Check these Rails mistakes first:
- The body came from
params. A parsed-and-reserialized object doesn't verify, because key order and whitespace are part of what was signed. Userequest.raw_post. - The digest is binary.
OpenSSL::HMAC.digestreturns a binary string; the signature is hex, which is whathexdigestreturns. - The header was compared whole. During a rotation it carries one entry per live secret, so an equality check on the whole header fails on every delivery in the window.
- The secret doesn't match. Compare the
x-sume-webhook-secret-fingerprintheader with the fingerprint shown beside the secret in the dashboard; Debug Sume webhook delivery covers the rest.
What should the action do after it verifies?
Answer fast and move the work to a job. Sume allows 10 seconds per attempt, retries a slow endpoint and makes up to 10 attempts. Solid Queue, the default Active Job backend since Rails 8.0, persists jobs in your database, so perform_later stores the event before head :no_content answers.
In the job, dedupe on request_id for run webhooks and on job_id for job webhooks, since retries repeat them, and route on event. The action answers 204 for every verified delivery, including event types you don't recognize; Sume's docs say that stops a newly added event type from becoming a 500 and a retry storm. A run webhook's payload is byte-identical to the data of GET /v1/format-runs/{run_id}, so one job can handle a webhook and a poll alike.
Sources
- Run webhooks
- Webhooks
- Verifying webhooks
- Rails API: ActionDispatch::Request (read 2026-09-28)
- Rails API: RequestForgeryProtection::ClassMethods (read 2026-09-28)
- Rails API: AbstractController::Callbacks (read 2026-09-28)
- Rails API: ActiveSupport::SecurityUtils (read 2026-09-28)
- Rails API: ActionController::Head (read 2026-09-28)
- Rails API: ActionDispatch::Http::Headers (read 2026-09-28)
- Rails Guides: Using Rails for API-only Applications (read 2026-09-28)
- Rails Guides: Active Job Basics (read 2026-09-28)
- Rails Guides: Layouts and Rendering (read 2026-09-28)
- Ruby 3.4: OpenSSL::HMAC (read 2026-09-28)
Related posts
More in Integrations
- React Native API integration: call an AI video API safely
In React Native, call your own backend with fetch and let it hold the API key. React Native's docs warn that anything in the app bundle is readable.
- Remotion with Claude Code: add AI video, voice and music
Set up Remotion's Agent Skills for Claude Code, then load generated clips, narration and music as files, with TTS word timings as captions.
- Replicate MCP server: remote and local setup for Claude
Replicate's MCP server is hosted at mcp.replicate.com or runs locally with npx replicate-mcp. Both use a Replicate API token. Setup per client.
- Runway MCP: connect Runway to Claude, Cursor, and ChatGPT
Runway MCP runs at mcp.runwayml.com/mcp. Add it as a connector or plugin, sign in with Runway, no API key; generations use your Runway credits.
Written by Sume