# Verifica firmas de webhook en Ruby y Rails

> Verifica las firmas de los webhooks de Ikiform en Sinatra con OpenSSL y un test de Minitest, y en Rails con la gema standardwebhooks y request.raw_post.

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

Lee el cuerpo sin procesar (`request.body.read` en Sinatra, `request.raw_post` en Rails) y las tres cabeceras `webhook-*`. Calcula `OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")` con el secreto `whsec_` decodificado de base64 como clave, codifícalo en base64 y compáralo con cada firma `v1` usando `OpenSSL.secure_compare`. En Rails, la gema `standardwebhooks` hace toda la comprobación en `Webhook#verify`.

Sinatra tiene un verificador sin dependencias con una suite de Minitest; Rails tiene la gema `standardwebhooks` en un controlador de API y un job que sabe lidiar con las repeticiones. Cualquiera de los dos verificadores funciona en cualquiera de los dos frameworks, y también en Hanami o Roda, porque todos se apoyan en Rack.

## Qué cambia en Ruby

Rack convierte cada cabecera de la petición en una clave del entorno: `webhook-id` pasa a ser `HTTP_WEBHOOK_ID`. Rails lo oculta tras `request.headers["webhook-id"]`, que traduce el nombre por ti. Sinatra no, así que `request.env["webhook-id"]` siempre es `nil`, y un verificador que recibe cabeceras `nil` falla con `missing_headers` aunque Ikiform las haya enviado. Rails tiene su propia versión de la trampa del cuerpo: analiza un cuerpo JSON en `params` por el content type `application/json`, y `wrap_parameters` puede copiar las claves bajo el nombre del controlador. Ninguna de las dos cosas cambia `request.raw_post`, que es el único valor que hay que verificar.

Diagrama: Dónde acaba una cabecera de webhook de Ikiform en Sinatra y en Rails

1. Ikiform envía una cabecera webhook-id en minúsculas.
2. En Sinatra, Rack la guarda como HTTP_WEBHOOK_ID en el entorno de la petición, así que léela de ahí.
3. request.body.read de Sinatra devuelve el cuerpo sin procesar, que se verifica antes de JSON.parse.
4. En Rails, request.headers traduce por ti el nombre de la cabecera a la clave de Rack, y request.raw_post devuelve el cuerpo sin procesar para que lo verifique la gema.

La misma cabecera, dos formas de escribirla. El verificador solo necesita los tres valores en un hash con claves en minúsculas.

## Sinatra con 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")` y `pack("m0")` son base64 estricto sin la librería `base64`, que dejó de ser una gema por defecto en Ruby 3.4 y, si no, hay que añadirla al Gemfile.
- `OpenSSL.secure_compare` es de tiempo constante y necesita Ruby 3.0 o posterior. Calcula primero el hash de ambos valores, así que las longitudes distintas no son un problema.
- Construir la cadena firmada a partir de un id ASCII y un cuerpo binario es seguro: Ruby conserva los bytes del cuerpo tal cual.
- Sinatra no analiza los cuerpos JSON, así que `request.body.read` es la primera lectura. Evita middleware como `Rack::JSONBodyParser` en esta ruta.
- `halt 400` detiene la ruta con el motivo como cuerpo, que luego aparece en el registro de entregas de Ikiform, así que un secreto incorrecto se lee ahí como `no_matching_signature`.
- Rack 3 ya no garantiza que el cuerpo de la petición se pueda rebobinar, así que léelo una vez en una cadena y usa esa cadena tanto para verificar como para analizar.

## Un test de Minitest

`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
```

Ejecútalo con `ruby ikiform_signature_test.rb`. Usa el [vector de prueba](https://ikiform.com/es/guides/webhook-signature-verification.md#test-vector) con un `now` fijo. `JSON.pretty_generate` es la forma en que un cuerpo se vuelve a serializar en Ruby, así que ese caso demuestra que la comprobación lo detectaría. En un proyecto que use Bundler, añade `minitest` al grupo de test del Gemfile: viene con Ruby como gema incluida, y Bundler solo carga las gemas que lista el Gemfile.

## Rails con la gema standardwebhooks

Añade `gem "standardwebhooks"` al Gemfile y ejecuta `bundle install`. Guarda el secreto con `bin/rails credentials:edit` en `ikiform.webhook_secret`, o léelo de `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` no tiene protección CSRF. Si el controlador hereda en cambio de `ApplicationController`, añade `skip_forgery_protection`, o Rails rechazará el envío.
- `request.raw_post` es el cuerpo tal como se envió, guardado en caché tras la primera lectura, así que llamarlo dos veces es seguro. No verifiques `params.to_json`: Rails ya ha analizado y fusionado el JSON en `params`, y los bytes son distintos.
- La gema lee las cabeceras de un hash con claves exactas en minúsculas, así que constrúyelo como se muestra. Comprueba la marca de tiempo y cada firma `v1`, y devuelve el JSON analizado.
- `perform_later` pone el trabajo en cola y `head :no_content` responde `204` al instante, holgadamente dentro de los 15 segundos de Ikiform. El job puede saltarse los ids de entrega que ya haya visto.

## Saltar las repeticiones en el job

Responder `204` al instante y hacer el trabajo en un job es la estructura correcta, y significa que el job tiene que soportar recibir la misma entrega dos veces: cuando Ikiform no recibe tu respuesta en 15 segundos, reintenta con el mismo `webhook-id`, y tu controlador pone en cola un segundo job. Un índice único hace que el segundo no haga nada:

`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
```

La inserción y el trabajo comparten una transacción, así que si el email falla, el id se revierte y el reintento propio del job vuelve a ejecutarse. `RecordNotUnique` viene del índice único de la base de datos, que se mantiene incluso cuando dos jobs se ejecutan a la vez en workers distintos; un `validates :delivery_id, uniqueness: true` a nivel de modelo por sí solo no lo haría. `SubmissionMailer` representa lo que sea que el job haga con los campos.

> La versión actual de la gema necesita Ruby 3.3 o posterior. En un Ruby anterior, usa en el controlador el módulo `IkiformSignature` de arriba; funciona en Ruby 3.0 y posteriores.

## Comprueba que funciona

1. Ejecuta el archivo de Minitest. Cuatro ejecuciones, ningún fallo.
2. Despliega detrás de una URL `https:` pública y añádela en **Webhooks** en el panel.
3. Pon el secreto en las credentials o en `IKIFORM_WEBHOOK_SECRET` y reinicia el servidor de la app.
4. Elige **Send test**. El registro de entregas muestra `204`, y el log de Rails muestra la petición llegando a `IkiformWebhooksController#create` sin ningún aviso de CSRF.
5. Cambia un carácter del secreto y envía otra prueba. El registro muestra `400` con el motivo.

## Errores comunes

| Problema | Causa y solución |
| --- | --- |
| `missing_headers` en Sinatra | El código leyó `request.env["webhook-id"]`. La clave de Rack es `HTTP_WEBHOOK_ID` |
| `ActionController::InvalidAuthenticityToken` en Rails | El controlador hereda la protección CSRF. Usa `ActionController::API` o `skip_forgery_protection` |
| `cannot load such file -- base64` tras actualizar Ruby | Ruby 3.4 sacó `base64` de las gemas por defecto. Añádela al Gemfile, o usa `pack("m0")` como arriba |
| `NoMethodError: undefined method 'secure_compare'` | Ruby anterior a 3.0. Actualiza, o usa `Rack::Utils.secure_compare`, que devuelve `false` si las longitudes difieren |
| La firma falla solo en Rails | Algo analizó el cuerpo antes del controlador, o `params` se volvió a codificar. Verifica `request.raw_post` |

## Preguntas

### ¿Cómo obtengo el cuerpo sin procesar de la petición en Rails?

Usa request.raw_post. Cuando se ejecuta tu acción, Rails ya ha analizado un cuerpo JSON en params, pero raw_post sigue conteniendo los bytes exactamente como se enviaron.

### ¿Cómo leo una cabecera personalizada en Sinatra?

Léela del entorno de Rack, en mayúsculas, con los guiones cambiados por guiones bajos y HTTP_ delante: webhook-id es request.env["HTTP_WEBHOOK_ID"].

### ¿Hay una gema de Standard Webhooks para Ruby?

Sí, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) recibe el secreto whsec_ y un hash con claves de cabecera en minúsculas, y lanza WebhookVerificationError cuando falla una comprobación.

### ¿Tengo que saltarme la protección CSRF para un webhook en Rails?

Solo si el controlador hereda de ActionController::Base. Los controladores ActionController::API no tienen comprobación CSRF. En cualquier caso, la firma es la protección de esta acción.

### ¿Por qué OpenSSL::HMAC.hexdigest no coincide con la firma?

Las firmas de Standard Webhooks son base64, no hex. Usa OpenSSL::HMAC.digest para obtener bytes sin procesar y codifícalos con [digest].pack("m0"); luego compáralos con el valor que va después de v1, en la cabecera.

## Más guías

- [Verifica las firmas de webhook](https://ikiform.com/es/guides/webhook-signature-verification.md): Comprueba que un webhook de formulario viene de verdad de Ikiform: el esquema HMAC-SHA256 de Standard Webhooks, un vector de prueba, cuerpos sin procesar, marcas de tiempo y rotación de secretos.
- [PHP](https://ikiform.com/es/guides/webhook-signature-verification/php.md): Verifica las firmas de los webhooks de Ikiform en PHP puro con hash_hmac y hash_equals, y en Laravel con la librería de Standard Webhooks y una excepción de CSRF.
- [Python](https://ikiform.com/es/guides/webhook-signature-verification/python.md): Verifica las firmas de los webhooks de Ikiform en Flask, FastAPI y Django, con el paquete standardwebhooks o la librería estándar, más un test de pytest.
- [Go net/http](https://ikiform.com/es/guides/webhook-signature-verification/go.md): Verifica las firmas de los webhooks de Ikiform en Go con net/http: lee el cuerpo una vez, compruébalo con crypto/hmac, pruébalo o usa la librería de Standard Webhooks.
- [Node.js](https://ikiform.com/es/guides/webhook-signature-verification/nodejs.md): Verifica las firmas de los webhooks de Ikiform en Node.js: cuerpos sin procesar en Express y Fastify, el SDK, el paquete standardwebhooks y una versión probada con node:crypto.
