Verify a Sume webhook in Rails: raw_post, skip_forgery_protection

A Rails controller that verifies Sume's sume-v1 HMAC over the raw body, accepts the rotation header, refuses an empty secret and skips CSRF for that route only.

5 min readSume
All posts

In Rails, read the body with request.raw_post, compute HMAC-SHA256 over <timestamp>.<raw_body>, compare it to every sume-v1= entry in the x-sume-webhook-signature header, and add skip_forgery_protection to that controller only. Sume's signature authenticates the request, so a CSRF token does not apply to a server-to-server POST.

The docs show the JavaScript SDK's verifyWebhook and describe the scheme on Verifying webhooks, so this is a hand-written Ruby verifier, and it has to refuse an empty secret.

What does Sume sign, and with what?

Sume signs every delivery with HMAC-SHA256 over <timestamp>.<raw_body> and sends it as sume-v1=<hex>, with the timestamp in x-sume-webhook-timestamp. The signing secret is yours per workspace: read it on the dashboard Webhooks tab or from GET /v1/webhooks/signing-secret with a key carrying account:read, and store it as SUME_COM_WEBHOOK_SIGNING_SECRET.

During a rotation the signature header carries one entry per live secret, newest first, comma-separated, for 24 hours. A verifier that compares the whole header for equality fails on every delivery in that window, so split on commas and accept any match.

What does the controller look like?

Rails documents skip_forgery_protection as a wrapper for skip_before_action :verify_authenticity_token. Use it on the webhook controller and nowhere else. The verifier checks the secret is non-empty first, so a missing environment variable fails closed instead of signing with an empty string:

class SumeWebhooksController < ApplicationController
  skip_forgery_protection

  def create
    raw = request.raw_post
    return head :unauthorized unless verified?(raw)
    event = JSON.parse(raw)
    # dedupe on event["job_id"] (job events), then enqueue work
    head :no_content
  end

  private

  def verified?(raw)
    secret = ENV["SUME_COM_WEBHOOK_SIGNING_SECRET"].to_s
    ts = request.headers["X-Sume-Webhook-Timestamp"].to_s
    return false if secret.empty? || ts !~ /\A\d+\z/
    return false if (Time.now.to_i - ts.to_i).abs > 300

    digest = OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
    expected = "sume-v1=#{digest}"
    request.headers["X-Sume-Webhook-Signature"].to_s.split(",").map(&:strip).count { |e|
      e.bytesize == expected.bytesize &&
        ActiveSupport::SecurityUtils.secure_compare(e, expected)
    } > 0
  end
end

What are the traps?

Verify before you parse. The signature covers the exact bytes Sume sent, and key order and whitespace are part of what was signed, so never re-serialize a parsed hash and compare that.

Return a fast 2xx after storing the event. Job webhooks retry up to 10 attempts with a fixed 30 second delay and a 10 second timeout per attempt, so a slow handler burns the budget. Treat job_id as the idempotency key for job events, as the Webhooks page says, and treat unknown event names as 204 rather than a 500, so a new event type cannot start a retry storm.

The replay window here is 300 seconds, the SDK default. If your Rails host clock drifts, every delivery fails the timestamp check before any HMAC is computed, so check NTP before blaming the secret.

How do I debug a signature that will not verify?

Compare fingerprints. Every delivery carries x-sume-webhook-secret-fingerprint, and the dashboard shows the same value beside the secret, so you can confirm you are holding the right secret without ever pasting it into a ticket.

Next check that no middleware has consumed or rewritten the body, and that the route is the one with skip_forgery_protection. Polling status_url stays valid as a backup for any delivery that never arrives, because delivery is an optimization and not the only recovery path.

How do I test it without waiting for a real run?

Use two tools Sume provides. First, Send test on the dashboard Webhooks page, or POST /v1/webhooks/test-deliveries with a key carrying account:write, posts a dummy signed webhook.test payload to a URL you type. It never replays a real job and has no job_id, so it proves your route, your secret and your signature check without touching paid work. Your handler should return a fast 2xx for it and avoid treating it as a job.

Second, once you have a real job, Redeliver re-posts that job's real terminal event with a fresh timestamp and signature, through the dashboard delivery row or POST /v1/jobs/{job_id}/webhook/redeliver with jobs:write. It still works after the automatic attempts are exhausted, and it does not consume one of the ten.

In a Rails request spec, build the signature the same way the controller does: take the raw JSON string, a current Unix timestamp, compute the HMAC-SHA256 over "#{ts}.#{body}" with a test secret, and send it as sume-v1=<hex>. Add negative cases: a wrong secret, a timestamp ten minutes old, a missing header and an empty configured secret, and assert each returns 401.

Sources

Related posts

More in Developers

All Developers posts

Written by Sume