# Verificar assinaturas de webhook em Ruby e Rails

> Verifique assinaturas de webhook do Ikiform no Sinatra com OpenSSL e um teste Minitest, e no Rails com a gem standardwebhooks e request.raw_post.

Publicado em 2026-10-25 · https://ikiform.com/pt-br/guides/webhook-signature-verification/ruby

Leia o corpo bruto (`request.body.read` no Sinatra, `request.raw_post` no Rails) e os três headers `webhook-*`. Calcule `OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")` com o segredo `whsec_` decodificado de base64 como chave, codifique em base64 e compare com cada assinatura `v1` usando `OpenSSL.secure_compare`. No Rails, a gem `standardwebhooks` faz toda a verificação em `Webhook#verify`.

O Sinatra ganha um verificador sem dependências com uma suíte Minitest; o Rails ganha a gem `standardwebhooks` num controller de API e um job que lida com repetições. Qualquer um dos verificadores funciona em qualquer um dos frameworks, e no Hanami ou no Roda também, já que todos rodam sobre o Rack.

## O que muda no Ruby

O Rack transforma cada header da requisição numa chave de ambiente: `webhook-id` vira `HTTP_WEBHOOK_ID`. O Rails esconde isso atrás de `request.headers["webhook-id"]`, que mapeia o nome para você. O Sinatra não, então `request.env["webhook-id"]` é sempre `nil`, e um verificador que recebe headers `nil` falha com `missing_headers` mesmo que o Ikiform os tenha enviado. O Rails tem a sua própria versão da armadilha do corpo: ele faz o parse de um corpo JSON em `params` por causa do content type `application/json`, e o `wrap_parameters` pode copiar as chaves sob o nome do controller. Nenhum dos dois muda `request.raw_post`, que é o único valor a verificar.

Diagrama: Onde um header de webhook do Ikiform vai parar no Sinatra e no Rails

1. O Ikiform envia um header webhook-id em minúsculas.
2. No Sinatra, o Rack o guarda como HTTP_WEBHOOK_ID no ambiente da requisição, então leia-o de lá.
3. O request.body.read do Sinatra devolve o corpo bruto, que é verificado antes do JSON.parse.
4. No Rails, o request.headers mapeia o nome do header para a chave do Rack por você, e o request.raw_post devolve o corpo bruto para a gem verificar.

Mesmo header, duas grafias. O verificador só precisa dos três valores num hash com chaves em minúsculas.

## Sinatra com 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")` e `pack("m0")` são base64 estrito sem a biblioteca `base64`, que deixou de ser uma gem padrão no Ruby 3.4 e, caso contrário, precisa ser adicionada ao Gemfile.
- `OpenSSL.secure_compare` é de tempo constante e precisa do Ruby 3.0 ou posterior. Ele calcula o hash dos dois valores antes, então tamanhos diferentes não são problema.
- Montar a string assinada a partir de um id ASCII e de um corpo binário é seguro: o Ruby mantém os bytes do corpo como estão.
- O Sinatra não faz o parse de corpos JSON, então `request.body.read` é a primeira leitura. Evite middlewares como `Rack::JSONBodyParser` nesta rota.
- `halt 400` interrompe a rota com o motivo como corpo, que então aparece no log de entregas do Ikiform, então um segredo errado aparece ali como `no_matching_signature`.
- O Rack 3 não garante mais que o corpo da requisição possa ser rebobinado, então leia-o uma vez numa string e use essa string tanto para verificar quanto para fazer o parse.

## Um teste com 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
```

Rode com `ruby ikiform_signature_test.rb`. Ele usa o [vetor de teste](https://ikiform.com/pt-br/guides/webhook-signature-verification.md#test-vector) com um `now` fixo. `JSON.pretty_generate` é como um corpo é reserializado em Ruby, então esse caso mostra que a verificação o pegaria. Num projeto que usa Bundler, adicione `minitest` ao grupo de teste do Gemfile: ele vem com o Ruby como bundled gem, e o Bundler só carrega as gems listadas no Gemfile.

## Rails com a gem standardwebhooks

Adicione `gem "standardwebhooks"` ao Gemfile e rode `bundle install`. Guarde o segredo com `bin/rails credentials:edit` em `ikiform.webhook_secret`, ou leia-o 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` não tem proteção CSRF. Se o controller herdar de `ApplicationController`, adicione `skip_forgery_protection`, ou o Rails recusa o envio.
- `request.raw_post` é o corpo como foi enviado, guardado em cache depois da primeira leitura, então chamá-lo duas vezes é seguro. Não verifique `params.to_json`: o Rails já fez o parse do JSON e o mesclou em `params`, e os bytes são diferentes.
- A gem lê os headers de um hash com chaves exatas em minúsculas, então monte-o como mostrado. Ela confere o timestamp e cada assinatura `v1` e devolve o JSON parseado.
- `perform_later` coloca o trabalho na fila e `head :no_content` responde `204` na hora, bem dentro dos 15 segundos do Ikiform. O job pode ignorar ids de entrega que já viu.

## Ignorando repetições no job

Responder `204` na hora e fazer o trabalho num job é o formato certo, e isso significa que o job precisa lidar com a mesma entrega duas vezes: quando o Ikiform não recebe a sua resposta em 15 segundos, ele tenta de novo com o mesmo `webhook-id`, e o seu controller enfileira um segundo job. Um índice único faz o segundo não ter efeito:

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

A inserção e o trabalho compartilham uma transação, então, se o e-mail falhar, o id é revertido e a nova tentativa do próprio job roda de novo. `RecordNotUnique` vem do índice único do banco de dados, que vale mesmo quando dois jobs rodam no mesmo instante em workers diferentes; um `validates :delivery_id, uniqueness: true` no modelo, sozinho, não daria conta. `SubmissionMailer` representa o que quer que o job faça com os campos.

> A versão atual da gem precisa do Ruby 3.3 ou posterior. Num Ruby mais antigo, use no controller o módulo `IkiformSignature` acima; ele roda no Ruby 3.0 e posteriores.

## Confira se funciona

1. Rode o arquivo Minitest. Quatro execuções, nenhuma falha.
2. Faça o deploy atrás de uma URL `https:` pública e adicione-a em **Webhooks** no painel.
3. Defina o segredo nas credentials ou em `IKIFORM_WEBHOOK_SECRET` e reinicie o servidor da aplicação.
4. Escolha **Send test**. O log de entregas mostra `204`, e o log do Rails mostra a requisição chegando a `IkiformWebhooksController#create` sem aviso de CSRF.
5. Mude um caractere do segredo e envie outro teste. O log mostra `400` com o motivo.

## Pegadinhas

| Problema | Causa e correção |
| --- | --- |
| `missing_headers` no Sinatra | O código leu `request.env["webhook-id"]`. A chave do Rack é `HTTP_WEBHOOK_ID` |
| `ActionController::InvalidAuthenticityToken` no Rails | O controller herda a proteção CSRF. Use `ActionController::API` ou `skip_forgery_protection` |
| `cannot load such file -- base64` depois de atualizar o Ruby | O Ruby 3.4 tirou o `base64` das gems padrão. Adicione-o ao Gemfile, ou use `pack("m0")` como acima |
| `NoMethodError: undefined method 'secure_compare'` | Ruby anterior ao 3.0. Atualize, ou use `Rack::Utils.secure_compare`, que devolve `false` para tamanhos diferentes |
| A assinatura só falha no Rails | Algo fez o parse do corpo antes do controller, ou `params` foi recodificado. Verifique `request.raw_post` |

## Perguntas

### Como pego o corpo bruto da requisição no Rails?

Use request.raw_post. Quando a sua action roda, o Rails já fez o parse de um corpo JSON em params, mas o raw_post ainda guarda os bytes exatamente como foram enviados.

### Como leio um header personalizado no Sinatra?

Leia-o do ambiente do Rack, em maiúsculas, com hífens trocados por underscores e HTTP_ na frente: webhook-id é request.env["HTTP_WEBHOOK_ID"].

### Existe uma gem do Standard Webhooks para Ruby?

Sim, a standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) recebe o segredo whsec_ e um hash com chaves de header em minúsculas, e lança WebhookVerificationError quando uma verificação falha.

### Preciso pular a proteção CSRF num webhook do Rails?

Só se o controller herdar de ActionController::Base. Controllers ActionController::API não têm verificação de CSRF. De qualquer forma, a assinatura é a proteção desta action.

### Por que o OpenSSL::HMAC.hexdigest não bate com a assinatura?

As assinaturas do Standard Webhooks são base64, não hex. Use OpenSSL::HMAC.digest para obter bytes brutos e codifique-os com [digest].pack("m0"), depois compare com o valor que vem depois de v1, no header.

## Mais guias

- [Verificar assinaturas de webhook](https://ikiform.com/pt-br/guides/webhook-signature-verification.md): Confira se um webhook de formulário veio mesmo do Ikiform: o esquema HMAC-SHA256 do Standard Webhooks, um vetor de teste, corpos brutos, timestamps e rotação de segredo.
- [PHP](https://ikiform.com/pt-br/guides/webhook-signature-verification/php.md): Verifique assinaturas de webhook do Ikiform em PHP puro com hash_hmac e hash_equals, e no Laravel com a biblioteca do Standard Webhooks e uma exceção de CSRF.
- [Python](https://ikiform.com/pt-br/guides/webhook-signature-verification/python.md): Verifique assinaturas de webhook do Ikiform no Flask, FastAPI e Django, com o pacote standardwebhooks ou a biblioteca padrão, mais um teste com pytest.
- [Go net/http](https://ikiform.com/pt-br/guides/webhook-signature-verification/go.md): Verifique assinaturas de webhook do Ikiform em Go com net/http: leia o corpo uma vez, confira com crypto/hmac, teste, ou use a biblioteca do Standard Webhooks.
- [Node.js](https://ikiform.com/pt-br/guides/webhook-signature-verification/nodejs.md): Verifique assinaturas de webhook do Ikiform em Node.js: corpos brutos no Express e no Fastify, o SDK, o pacote standardwebhooks e uma versão testada com node:crypto.
