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.

6 min readSume
All posts

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.

From the Rails API docs for ActionDispatch::Request, Http::Headers and SecurityUtils, Ruby's OpenSSL::HMAC docs and Sume's Run webhooks and Webhooks pages, read 2026-09-28.
StepSume ruleRuby or Rails
Raw bodyVerify the raw bytes before any JSON parserequest.raw_post
Headersx-sume-webhook-timestamp, x-sume-webhook-signaturerequest.headers["X-Sume-Webhook-Signature"]
DigestHMAC-SHA256 over <timestamp>.<raw_body>, hex-encodedOpenSSL::HMAC.hexdigest("SHA256", secret, data)
CompareConstant time; accept any sume-v1= entrysecure_compare against each entry of header.split(",")
Replay windowReject 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
end

Why 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. Use request.raw_post.
  • The digest is binary. OpenSSL::HMAC.digest returns a binary string; the signature is hex, which is what hexdigest returns.
  • 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-fingerprint header 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

Related posts

More in Integrations

All Integrations posts

Written by Sume