# Verifikasi tanda tangan webhook di Ruby dan Rails

> Verifikasi tanda tangan webhook Ikiform di Sinatra dengan OpenSSL dan test Minitest, dan di Rails dengan gem standardwebhooks dan request.raw_post.

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

Baca body mentah (`request.body.read` di Sinatra, `request.raw_post` di Rails) dan ketiga header `webhook-*`. Hitung `OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")` dengan secret `whsec_` hasil decode base64 sebagai key, encode base64 hasilnya, dan bandingkan dengan setiap tanda tangan `v1` memakai `OpenSSL.secure_compare`. Di Rails, gem `standardwebhooks` melakukan seluruh pemeriksaan di `Webhook#verify`.

Sinatra mendapat verifier tanpa dependency dengan suite Minitest; Rails mendapat gem `standardwebhooks` di controller API dan job yang mampu menangani pengulangan. Kedua verifier berfungsi di framework mana pun, juga di Hanami atau Roda, karena semuanya berjalan di atas Rack.

## Apa yang berbeda di Ruby

Rack mengubah setiap header request menjadi key environment: `webhook-id` menjadi `HTTP_WEBHOOK_ID`. Rails menyembunyikannya di balik `request.headers["webhook-id"]`, yang memetakan nama untuk Anda. Sinatra tidak, jadi `request.env["webhook-id"]` selalu `nil`, dan verifier yang menerima header `nil` gagal dengan `missing_headers` meski Ikiform mengirimnya. Rails punya versi sendiri dari jebakan body: Rails mem-parsing body JSON ke `params` karena content type `application/json`, dan `wrap_parameters` mungkin menyalin key di bawah nama controller. Keduanya tidak mengubah `request.raw_post`, satu-satunya nilai yang perlu diverifikasi.

Diagram: Di mana header webhook Ikiform berakhir di Sinatra dan di Rails

1. Ikiform mengirim header webhook-id dalam huruf kecil.
2. Di Sinatra, Rack menyimpannya sebagai HTTP_WEBHOOK_ID di environment request, jadi baca dari sana.
3. request.body.read di Sinatra mengembalikan body mentah, yang diverifikasi sebelum JSON.parse.
4. Di Rails, request.headers memetakan nama header ke key Rack untuk Anda, dan request.raw_post mengembalikan body mentah untuk diverifikasi gem.

Header sama, dua ejaan. Verifier hanya butuh ketiga nilai dalam hash dengan key huruf kecil.

## Sinatra dengan 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")` dan `pack("m0")` adalah base64 ketat tanpa library `base64`, yang tidak lagi menjadi default gem sejak Ruby 3.4 dan jika tidak harus ditambahkan ke Gemfile.
- `OpenSSL.secure_compare` bersifat constant-time dan butuh Ruby 3.0 atau lebih baru. Fungsi ini meng-hash kedua nilai lebih dulu, jadi panjang yang berbeda tidak masalah.
- Menyusun string yang ditandatangani dari id ASCII dan body biner aman: Ruby mempertahankan byte body apa adanya.
- Sinatra tidak mem-parsing body JSON, jadi `request.body.read` adalah pembacaan pertama. Hindari middleware seperti `Rack::JSONBodyParser` di route ini.
- `halt 400` menghentikan route dengan alasan sebagai body, yang kemudian tampil di log pengiriman Ikiform, jadi secret yang salah terbaca sebagai `no_matching_signature` di sana.
- Rack 3 tidak lagi menjamin body request bisa di-rewind, jadi baca sekali ke dalam string dan gunakan string itu untuk verifikasi maupun parsing.

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

Jalankan dengan `ruby ikiform_signature_test.rb`. Test memakai [test vector](https://ikiform.com/id/guides/webhook-signature-verification.md#test-vector) dengan `now` yang tetap. `JSON.pretty_generate` adalah cara body diserialisasi ulang di Ruby, jadi kasus itu menunjukkan pemeriksaan akan menangkapnya. Di proyek yang memakai Bundler, tambahkan `minitest` ke grup test di Gemfile: gem itu disertakan bersama Ruby sebagai bundled gem, dan Bundler hanya memuat gem yang tercantum di Gemfile.

## Rails dengan gem standardwebhooks

Tambahkan `gem "standardwebhooks"` ke Gemfile dan jalankan `bundle install`. Simpan secret dengan `bin/rails credentials:edit` di bawah `ikiform.webhook_secret`, atau baca dari `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` tidak punya perlindungan CSRF. Jika controller mewarisi `ApplicationController`, tambahkan `skip_forgery_protection`, atau Rails menolak post.
- `request.raw_post` adalah body seperti dikirim, di-cache setelah pembacaan pertama, jadi memanggilnya dua kali aman. Jangan verifikasi `params.to_json`: Rails sudah mem-parsing dan menggabungkan JSON ke `params`, dan byte-nya berbeda.
- Gem membaca header dari hash dengan key huruf kecil yang persis, jadi susun seperti contoh. Gem memeriksa timestamp dan setiap tanda tangan `v1`, lalu mengembalikan JSON yang sudah di-parse.
- `perform_later` memasukkan pekerjaan ke antrean dan `head :no_content` langsung menjawab `204`, jauh di bawah 15 detik Ikiform. Job bisa melewati id pengiriman yang sudah pernah dilihatnya.

## Melewati pengulangan di job

Langsung menjawab `204` dan mengerjakan tugas di job adalah bentuk yang tepat, dan itu berarti job harus mampu menangani pengiriman yang sama dua kali: saat Ikiform tidak mendapat jawaban Anda dalam 15 detik, Ikiform mencoba ulang dengan `webhook-id` yang sama, dan controller Anda memasukkan job kedua ke antrean. Unique index membuat yang kedua menjadi 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
```

Insert dan pekerjaannya berbagi satu transaksi, jadi jika email gagal, id di-rollback dan percobaan ulang job itu sendiri berjalan lagi. `RecordNotUnique` berasal dari unique index database, yang tetap berlaku meski dua job berjalan bersamaan di worker berbeda; `validates :delivery_id, uniqueness: true` di level model saja tidak cukup. `SubmissionMailer` mewakili apa pun yang dilakukan job dengan field.

> Rilis gem saat ini butuh Ruby 3.3 atau lebih baru. Di Ruby yang lebih lama, gunakan modul `IkiformSignature` di atas di controller; modul itu berjalan di Ruby 3.0 dan yang lebih baru.

## Pastikan berfungsi

1. Jalankan file Minitest. Empat run, tanpa kegagalan.
2. Deploy di balik URL `https:` publik dan tambahkan di **Webhooks** di dashboard.
3. Atur secret di credentials atau `IKIFORM_WEBHOOK_SECRET` dan restart app server.
4. Pilih **Send test**. Log pengiriman menampilkan `204`, dan log Rails menunjukkan request sampai ke `IkiformWebhooksController#create` tanpa peringatan CSRF.
5. Ubah satu karakter secret dan kirim uji lagi. Log menampilkan `400` beserta alasannya.

## Hal yang sering menjebak

| Masalah | Penyebab dan solusi |
| --- | --- |
| `missing_headers` di Sinatra | Kode membaca `request.env["webhook-id"]`. Key Rack-nya adalah `HTTP_WEBHOOK_ID` |
| `ActionController::InvalidAuthenticityToken` di Rails | Controller mewarisi perlindungan CSRF. Gunakan `ActionController::API` atau `skip_forgery_protection` |
| `cannot load such file -- base64` setelah upgrade Ruby | Ruby 3.4 mengeluarkan `base64` dari default gem. Tambahkan ke Gemfile, atau gunakan `pack("m0")` seperti di atas |
| `NoMethodError: undefined method 'secure_compare'` | Ruby lebih lama dari 3.0. Upgrade, atau gunakan `Rack::Utils.secure_compare`, yang mengembalikan `false` untuk panjang yang berbeda |
| Tanda tangan hanya gagal di Rails | Ada yang mem-parsing body sebelum controller, atau `params` di-encode ulang. Verifikasi `request.raw_post` |

## Pertanyaan

### Bagaimana cara mendapatkan body request mentah di Rails?

Gunakan request.raw_post. Saat action Anda berjalan, Rails sudah mem-parsing body JSON ke params, tetapi raw_post tetap menyimpan byte persis seperti dikirim.

### Bagaimana cara membaca header kustom di Sinatra?

Baca dari environment Rack, dalam huruf besar dengan tanda hubung diganti garis bawah dan diawali HTTP_: webhook-id menjadi request.env["HTTP_WEBHOOK_ID"].

### Apakah ada gem Standard Webhooks untuk Ruby?

Ada, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) menerima secret whsec_ dan hash dengan key header huruf kecil, dan memunculkan WebhookVerificationError saat pemeriksaan gagal.

### Apakah saya perlu melewati perlindungan CSRF untuk webhook Rails?

Hanya jika controller mewarisi ActionController::Base. Controller ActionController::API tidak punya pemeriksaan CSRF. Bagaimanapun juga, tanda tangan adalah perlindungan untuk action ini.

### Mengapa OpenSSL::HMAC.hexdigest tidak cocok dengan tanda tangan?

Tanda tangan Standard Webhooks berupa base64, bukan hex. Gunakan OpenSSL::HMAC.digest untuk mendapat byte mentah dan encode dengan [digest].pack("m0"), lalu bandingkan dengan nilai setelah v1, di header.

## Panduan lainnya

- [Verifikasi tanda tangan webhook](https://ikiform.com/id/guides/webhook-signature-verification.md): Pastikan webhook formulir benar-benar berasal dari Ikiform: skema HMAC-SHA256 Standard Webhooks, test vector, body mentah, timestamp, dan rotasi secret.
- [PHP](https://ikiform.com/id/guides/webhook-signature-verification/php.md): Verifikasi tanda tangan webhook Ikiform di PHP biasa dengan hash_hmac dan hash_equals, dan di Laravel dengan library Standard Webhooks dan pengecualian CSRF.
- [Python](https://ikiform.com/id/guides/webhook-signature-verification/python.md): Verifikasi tanda tangan webhook Ikiform di Flask, FastAPI, dan Django, dengan paket standardwebhooks atau standard library, ditambah test pytest.
- [Go net/http](https://ikiform.com/id/guides/webhook-signature-verification/go.md): Verifikasi tanda tangan webhook Ikiform di Go dengan net/http: baca body sekali, periksa dengan crypto/hmac, uji, atau gunakan library Standard Webhooks.
- [Node.js](https://ikiform.com/id/guides/webhook-signature-verification/nodejs.md): Verifikasi tanda tangan webhook Ikiform di Node.js: body mentah di Express dan Fastify, SDK, paket standardwebhooks, dan versi node:crypto yang sudah diuji.
