# Verify webhook signatures in Ruby and Rails

> Verify Ikiform webhook signatures in Sinatra with OpenSSL and a Minitest test, and in Rails with the standardwebhooks gem and request.raw_post.

Published 2026-10-25 · https://ikiform.com/guides/webhook-signature-verification/ruby

Read the raw body (`request.body.read` in Sinatra, `request.raw_post` in Rails) and the three `webhook-*` headers. Compute `OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")` with the base64-decoded `whsec_` secret as the key, base64-encode it, and compare it with each `v1` signature using `OpenSSL.secure_compare`. In Rails, the `standardwebhooks` gem does the whole check in `Webhook#verify`.

Sinatra gets a dependency-free verifier with a Minitest suite; Rails gets the `standardwebhooks` gem in an API controller and a job that copes with repeats. Either verifier works in either framework, and in Hanami or Roda too, since all of them sit on Rack.

## What's different in Ruby

Rack turns every request header into an environment key: `webhook-id` becomes `HTTP_WEBHOOK_ID`. Rails hides that behind `request.headers["webhook-id"]`, which maps the name for you. Sinatra doesn't, so `request.env["webhook-id"]` is always `nil`, and a verifier that receives `nil` headers fails with `missing_headers` even though Ikiform sent them. Rails has its own version of the body trap: it parses a JSON body into `params` because of the `application/json` content type, and `wrap_parameters` may copy the keys under the controller's name. Neither changes `request.raw_post`, which is the only value to verify.

Diagram: Where an Ikiform webhook header ends up in Sinatra and in Rails

1. Ikiform sends a lowercase webhook-id header.
2. In Sinatra, Rack stores it as HTTP_WEBHOOK_ID in the request environment, so read it from there.
3. Sinatra's request.body.read returns the raw body, which is verified before JSON.parse.
4. In Rails, request.headers maps the header name to the Rack key for you, and request.raw_post returns the raw body for the gem to verify.

Same header, two spellings. The verifier only needs the three values in a hash with lowercase keys.

## Sinatra with OpenSSL

`ikiform_signature.rb`:

```text
require "openssl"

module IkiformSignature
  class Invalid < StandardError; end

  TOLERANCE = 5 * 60

  def self.verify!(body, headers, secret, now: Time.now.to_i)
    id = headers["webhook-id"].to_s
    timestamp = headers["webhook-timestamp"].to_s
    signatures = headers["webhook-signature"].to_s
    raise Invalid, "missing_headers" if id.empty? || timestamp.empty? || signatures.empty?
    raise Invalid, "invalid_timestamp" unless timestamp.match?(/\A[0-9]{1,12}\z/)

    age = now - timestamp.to_i
    raise Invalid, "timestamp_too_old" if age > TOLERANCE
    raise Invalid, "timestamp_too_new" if age < -TOLERANCE

    key = secret.delete_prefix("whsec_").unpack1("m0")
    expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
    matched = signatures.split(" ").any? do |entry|
      version, value = entry.split(",", 2)
      version == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
    end
    raise Invalid, "no_matching_signature" unless matched
  end
end
```

`app.rb`:

```text
require "json"
require "sinatra"
require_relative "ikiform_signature"

SECRET = ENV.fetch("IKIFORM_WEBHOOK_SECRET")

post "/webhooks/ikiform" do
  body = request.body.read
  headers = {
    "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
    "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
    "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"]
  }

  begin
    IkiformSignature.verify!(body, headers, SECRET)
  rescue IkiformSignature::Invalid => error
    halt 400, error.message
  end

  event = JSON.parse(body)
  logger.info("submission #{event["id"]}") if event["type"] == "submission.created"
  status 204
end
```

- `unpack1("m0")` and `pack("m0")` are strict base64 without the `base64` library, which stopped being a default gem in Ruby 3.4 and must otherwise be added to the Gemfile.
- `OpenSSL.secure_compare` is constant-time and needs Ruby 3.0 or later. It hashes both values first, so different lengths are fine.
- Building the signed string from an ASCII id and a binary body is safe: Ruby keeps the body's bytes as they are.
- Sinatra doesn't parse JSON bodies, so `request.body.read` is the first read. Avoid middleware such as `Rack::JSONBodyParser` on this route.
- `halt 400` stops the route with the reason as the body, which then shows in Ikiform's delivery log, so a wrong secret reads as `no_matching_signature` there.
- Rack 3 no longer promises that the request body can be rewound, so read it once into a string and use that string for both verifying and parsing.

## A Minitest test

`ikiform_signature_test.rb`:

```text
require "json"
require "minitest/autorun"
require_relative "ikiform_signature"

class IkiformSignatureTest < Minitest::Test
  SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
  ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
  BODY = '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'
  HEADERS = {
    "webhook-id" => "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
    "webhook-timestamp" => "1792300000",
    "webhook-signature" => "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA="
  }.freeze
  NOW = 1_792_300_000 + 30

  def test_accepts_the_vector
    assert_nil IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW)
  end

  def test_accepts_either_secret_during_a_roll
    both = HEADERS.merge("webhook-signature" => "#{HEADERS["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")
    assert_nil IkiformSignature.verify!(BODY, both, SECRET, now: NOW)
    assert_nil IkiformSignature.verify!(BODY, both, ROLLED, now: NOW)
  end

  def test_rejects_a_reserialized_body
    pretty = JSON.pretty_generate(JSON.parse(BODY))
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(pretty, HEADERS, SECRET, now: NOW) }
    assert_equal "no_matching_signature", error.message
  end

  def test_rejects_a_stale_timestamp
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW + 600) }
    assert_equal "timestamp_too_old", error.message
  end
end
```

Run it with `ruby ikiform_signature_test.rb`. It uses the [test vector](https://ikiform.com/guides/webhook-signature-verification.md#test-vector) with a fixed `now`. `JSON.pretty_generate` is how a body gets re-serialized in Ruby, so that case shows the check would catch it. In a project that uses Bundler, add `minitest` to the Gemfile's test group: it ships with Ruby as a bundled gem, and Bundler only loads gems the Gemfile lists.

## Rails with the standardwebhooks gem

Add `gem "standardwebhooks"` to the Gemfile and run `bundle install`. Store the secret with `bin/rails credentials:edit` under `ikiform.webhook_secret`, or read it from `ENV`.

`app/controllers/ikiform_webhooks_controller.rb`:

```text
class IkiformWebhooksController < ActionController::API
  def create
    webhook = StandardWebhooks::Webhook.new(Rails.application.credentials.dig(:ikiform, :webhook_secret))
    event = webhook.verify(request.raw_post, {
      "webhook-id" => request.headers["webhook-id"],
      "webhook-timestamp" => request.headers["webhook-timestamp"],
      "webhook-signature" => request.headers["webhook-signature"]
    })

    if event["type"] == "submission.created"
      IkiformSubmissionJob.perform_later(event["id"], event.dig("data", "fields"))
    end
    head :no_content
  rescue StandardWebhooks::WebhookVerificationError => error
    render plain: error.message, status: :bad_request
  end
end
```

`config/routes.rb`:

```text
Rails.application.routes.draw do
  post "webhooks/ikiform", to: "ikiform_webhooks#create"
end
```

- `ActionController::API` has no CSRF protection. If the controller inherits from `ApplicationController` instead, add `skip_forgery_protection`, or Rails refuses the post.
- `request.raw_post` is the body as sent, cached after the first read, so calling it twice is safe. Don't verify `params.to_json`: Rails has already parsed and merged the JSON into `params`, and the bytes differ.
- The gem reads the headers from a hash with exact lowercase keys, so build it as shown. It checks the timestamp and every `v1` signature, and returns the parsed JSON.
- `perform_later` queues the work and `head :no_content` answers `204` right away, well inside Ikiform's 15 seconds. The job can skip delivery ids it has already seen.

## Skipping repeats in the job

Answering `204` right away and doing the work in a job is the right shape, and it means the job must cope with the same delivery twice: when Ikiform doesn't get your answer within 15 seconds, it retries with the same `webhook-id`, and your controller queues a second job. A unique index makes the second one a no-op:

`db/migrate/20261025000000_create_webhook_deliveries.rb`:

```text
class CreateWebhookDeliveries < ActiveRecord::Migration[8.0]
  def change
    create_table :webhook_deliveries do |t|
      t.string :delivery_id, null: false
      t.timestamps
    end
    add_index :webhook_deliveries, :delivery_id, unique: true
  end
end
```

`app/jobs/ikiform_submission_job.rb`:

```text
class IkiformSubmissionJob < ApplicationJob
  queue_as :default

  def perform(delivery_id, fields)
    WebhookDelivery.transaction do
      WebhookDelivery.create!(delivery_id: delivery_id)
      SubmissionMailer.with(fields: fields).notify_team.deliver_now
    end
  rescue ActiveRecord::RecordNotUnique
    Rails.logger.info("Skipping repeated delivery #{delivery_id}")
  end
end
```

The insert and the work share a transaction, so if the mail fails the id is rolled back and the job's own retry runs again. `RecordNotUnique` comes from the database's unique index, which holds even when two jobs run at the same moment on different workers; a model-level `validates :delivery_id, uniqueness: true` alone would not. `SubmissionMailer` stands in for whatever the job does with the fields.

> The gem's current release needs Ruby 3.3 or later. On an older Ruby, use the `IkiformSignature` module above in the controller instead; it runs on Ruby 3.0 and later.

## Check it works

1. Run the Minitest file. Four runs, no failures.
2. Deploy behind a public `https:` URL and add it under **Webhooks** in the dashboard.
3. Set the secret in credentials or `IKIFORM_WEBHOOK_SECRET` and restart the app server.
4. Choose **Send test**. The delivery log shows `204`, and the Rails log shows the request reaching `IkiformWebhooksController#create` with no CSRF warning.
5. Change one character of the secret and send another test. The log shows `400` with the reason.

## Gotchas

| Problem | Cause and fix |
| --- | --- |
| `missing_headers` in Sinatra | The code read `request.env["webhook-id"]`. Rack's key is `HTTP_WEBHOOK_ID` |
| `ActionController::InvalidAuthenticityToken` in Rails | The controller inherits CSRF protection. Use `ActionController::API` or `skip_forgery_protection` |
| `cannot load such file -- base64` after upgrading Ruby | Ruby 3.4 moved `base64` out of the default gems. Add it to the Gemfile, or use `pack("m0")` as above |
| `NoMethodError: undefined method 'secure_compare'` | Ruby older than 3.0. Upgrade, or use `Rack::Utils.secure_compare`, which returns `false` for different lengths |
| The signature fails only on Rails | Something parsed the body before the controller, or `params` was re-encoded. Verify `request.raw_post` |

## Questions

### How do I get the raw request body in Rails?

Use request.raw_post. By the time your action runs, Rails has parsed a JSON body into params, but raw_post still holds the bytes exactly as sent.

### How do I read a custom header in Sinatra?

Read it from the Rack environment, upper-cased with dashes turned into underscores and HTTP_ in front: webhook-id is request.env["HTTP_WEBHOOK_ID"].

### Is there a Standard Webhooks gem for Ruby?

Yes, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) takes the whsec_ secret and a hash with lowercase header keys, and raises WebhookVerificationError when a check fails.

### Do I need to skip CSRF protection for a Rails webhook?

Only if the controller inherits from ActionController::Base. ActionController::API controllers have no CSRF check. The signature is the protection for this action either way.

### Why doesn't OpenSSL::HMAC.hexdigest match the signature?

Standard Webhooks signatures are base64, not hex. Use OpenSSL::HMAC.digest to get raw bytes and encode them with [digest].pack("m0"), then compare with the value after v1, in the header.

## More guides

- [Verify webhook signatures](https://ikiform.com/guides/webhook-signature-verification.md): Check that a form webhook really came from Ikiform: the Standard Webhooks HMAC-SHA256 scheme, a test vector, raw bodies, timestamps and secret rotation.
- [PHP](https://ikiform.com/guides/webhook-signature-verification/php.md): Verify Ikiform webhook signatures in plain PHP with hash_hmac and hash_equals, and in Laravel with the Standard Webhooks library and a CSRF exception.
- [Python](https://ikiform.com/guides/webhook-signature-verification/python.md): Verify Ikiform webhook signatures in Flask, FastAPI and Django, with the standardwebhooks package or the standard library, plus a pytest test.
- [Go net/http](https://ikiform.com/guides/webhook-signature-verification/go.md): Verify Ikiform webhook signatures in Go with net/http: read the body once, check it with crypto/hmac, test it, or use the Standard Webhooks library.
- [Node.js](https://ikiform.com/guides/webhook-signature-verification/nodejs.md): Verify Ikiform webhook signatures in Node.js: raw bodies in Express and Fastify, the SDK, the standardwebhooks package and a tested node:crypto version.
