35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English 35+ Years Experience Netherlands Based ⚡ Fast Response Times Ruby on Rails Experts AI-Powered Development Fixed Pricing Available Senior Architects Dutch & English
Rails Webhook Processing: Signature Verification, Idempotency and Background Delivery

Rails Webhook Processing: Signature Verification, Idempotency and Background Delivery

Roger Heykoop
Ruby on Rails, DevOps
Rails webhook processing done right: HMAC signature verification, idempotent handlers, Stripe and GitHub examples, background jobs and error recovery.

A Stripe webhook hit our endpoint. Payment confirmed, subscription activated, welcome email sent. Except the same event fired three times in quick succession — Stripe retries on anything other than a 2xx response, and our database was slow that afternoon. Three welcome emails. One very confused customer.

That was about twelve years ago. I’ve processed hundreds of millions of webhook events since then, and the lesson is always the same: webhook processing looks simple and is not. The provider fires and forgets. Your job is to verify the source, respond fast, and make the processing idempotent. Most Rails webhook tutorials cover none of that.

What Makes Rails Webhook Processing Hard

Webhooks arrive over HTTP. Your endpoint has roughly five seconds to respond with a 2xx or the provider marks the delivery failed and retries. If your response takes six seconds because you’re doing database work, sending email, and hitting third-party APIs synchronously — you get retried. Now you’ve processed the same event twice.

Three problems to solve:

  1. Authentication: Did this actually come from Stripe, or is someone crafting fake events?
  2. Idempotency: What happens when the same event arrives twice — or ten times?
  3. Latency: How do you respond in under five seconds when the real work takes thirty?

Rails makes all three solvable. Here’s how.

Signature Verification: Reject Everything Unsigned

Every serious webhook provider signs their payloads. Stripe uses HMAC-SHA256. GitHub uses HMAC-SHA256. Shopify does the same. The pattern is consistent: the provider includes a signature in a header, you recompute it using your shared secret, and you reject anything that doesn’t match. If you skip this step, anyone can POST to your endpoint with a fabricated event and trigger real side effects.

Stripe Webhook Verification in Rails

Stripe sends a Stripe-Signature header containing a timestamp and the HMAC digest. The timestamp prevents replay attacks — Stripe’s library rejects signatures older than five minutes.

# app/controllers/webhooks/stripe_controller.rb
class Webhooks::StripeController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_stripe_signature

  def create
    StripeWebhookJob.perform_later(
      @event.id,
      @event.type,
      @event.data.object.to_h
    )
    head :ok
  end

  private

  def verify_stripe_signature
    payload    = request.body.read
    sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
    secret     = Rails.application.credentials.stripe_webhook_secret

    @event = Stripe::Webhook.construct_event(payload, sig_header, secret)
  rescue Stripe::SignatureVerificationError => e
    Rails.logger.warn("Stripe signature verification failed: #{e.message}")
    head :bad_request
  end
end

Two critical details: skip_before_action :verify_authenticity_token is required — Stripe is not a browser and carries no CSRF token. And you must read request.body.read before anything else touches the body, because Stripe’s library validates the raw bytes, not the parsed JSON.

GitHub Webhook Verification

GitHub uses a simpler scheme — one header, no timestamp component:

# app/controllers/webhooks/github_controller.rb
class Webhooks::GithubController < ApplicationController
  skip_before_action :verify_authenticity_token
  before_action :verify_github_signature

  def create
    event_name = request.headers["X-GitHub-Event"]
    GithubWebhookJob.perform_later(event_name, request.raw_post)
    head :ok
  end

  private

  def verify_github_signature
    secret   = Rails.application.credentials.github_webhook_secret
    body     = request.body.read
    expected = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}"
    received = request.headers["X-Hub-Signature-256"].to_s

    unless ActiveSupport::SecurityUtils.secure_compare(received, expected)
      Rails.logger.warn("GitHub signature mismatch for #{request.remote_ip}")
      head :unauthorized and return
    end

    request.body.rewind
  end
end

ActiveSupport::SecurityUtils.secure_compare matters here. A naive == comparison short-circuits on the first mismatched byte, making it vulnerable to timing attacks. Constant-time comparison eliminates that.

A Reusable HMAC Verifier

If you’re integrating multiple providers with the same algorithm, extract the pattern once:

# app/services/webhook_signature_verifier.rb
class WebhookSignatureVerifier
  def self.valid?(body:, secret:, received:, algorithm: "SHA256", prefix: "sha256=")
    expected = "#{prefix}#{OpenSSL::HMAC.hexdigest(algorithm, secret, body)}"
    ActiveSupport::SecurityUtils.secure_compare(received.to_s, expected)
  end
end

Call it anywhere:

unless WebhookSignatureVerifier.valid?(
  body:     request.body.read,
  secret:   Rails.application.credentials.shopify_webhook_secret,
  received: request.headers["X-Shopify-Hmac-SHA256"],
  prefix:   ""  # Shopify doesn't include a prefix
)
  head :unauthorized and return
end

Idempotency: Process Once, No Matter How Many Times It Arrives

Stripe retries on 5xx responses and timeouts. GitHub retries on anything that isn’t 2xx. Your processing logic must be safe to run multiple times for the same event.

The cleanest approach: store every event ID you receive, and skip processing if you’ve already handled it.

Database-Backed Idempotency

# db/migrate/20260414120000_create_webhook_events.rb
class CreateWebhookEvents < ActiveRecord::Migration[8.0]
  def change
    create_table :webhook_events do |t|
      t.string  :provider,      null: false
      t.string  :event_id,      null: false
      t.string  :event_type,    null: false
      t.jsonb   :payload,       null: false, default: {}
      t.string  :status,        null: false, default: "pending"
      t.text    :error_message
      t.timestamps
    end

    add_index :webhook_events, [:provider, :event_id], unique: true
  end
end
# app/models/webhook_event.rb
class WebhookEvent < ApplicationRecord
  enum :status, { pending: "pending", processed: "processed", failed: "failed" }

  def self.process_once(provider:, event_id:, event_type:, payload:)
    record = create!(
      provider:   provider,
      event_id:   event_id,
      event_type: event_type,
      payload:    payload
    )
    yield(record)
    record.update!(status: :processed)
  rescue ActiveRecord::RecordNotUnique
    Rails.logger.info("Duplicate #{provider} event #{event_id} — skipping")
  end
end

The rescue ActiveRecord::RecordNotUnique on the database-level unique index is intentional. Checking-then-inserting has a TOCTOU race condition: two concurrent deliveries of the same event can both pass the exists? check and then both attempt the insert. Catching the constraint violation at the database level is airtight.

Using It in the Job

# app/jobs/stripe_webhook_job.rb
class StripeWebhookJob < ApplicationJob
  queue_as :webhooks

  retry_on StandardError, wait: :polynomially_longer, attempts: 5
  discard_on ActiveRecord::RecordNotUnique

  def perform(event_id, event_type, payload)
    WebhookEvent.process_once(
      provider:   "stripe",
      event_id:   event_id,
      event_type: event_type,
      payload:    payload
    ) do
      StripeEventHandler.handle(event_type, payload)
    end
  end
end

discard_on ActiveRecord::RecordNotUnique stops retries on duplicates before they even hit process_once. retry_on StandardError with polynomial backoff handles transient failures — database unavailable, third-party API timeout, whatever.

Routing Events: The Handler Registry Pattern

Keep the controller and job thin. Delegate to focused handler classes.

# app/services/stripe_event_handler.rb
class StripeEventHandler
  HANDLERS = {
    "checkout.session.completed"  => CheckoutCompletedHandler,
    "customer.subscription.deleted" => SubscriptionCancelledHandler,
    "invoice.payment_failed"      => PaymentFailedHandler
  }.freeze

  def self.handle(event_type, payload)
    handler_class = HANDLERS[event_type]

    if handler_class
      handler_class.new(payload).call
    else
      Rails.logger.debug("No handler registered for Stripe event: #{event_type}")
    end
  end
end

Each handler is a small class that does one thing:

# app/services/checkout_completed_handler.rb
class CheckoutCompletedHandler
  def initialize(payload)
    @payload = payload
  end

  def call
    customer_id = @payload.fetch("customer")
    user = User.find_by!(stripe_customer_id: customer_id)

    user.activate_subscription!
    UserMailer.welcome(user).deliver_later
  end
end

This structure makes testing straightforward. You don’t need to fire fake HTTP requests to test checkout activation — instantiate the handler directly in a unit test with a fixture payload. No controllers, no jobs, no database-level idempotency to wire around.

Testing Webhook Controllers

# spec/requests/webhooks/stripe_spec.rb
RSpec.describe "Webhooks::Stripe", type: :request do
  let(:secret)    { "whsec_test_secret" }
  let(:event_id)  { "evt_test_#{SecureRandom.hex(8)}" }
  let(:payload)   do
    {
      id:   event_id,
      type: "checkout.session.completed",
      data: { object: { customer: "cus_abc123" } }
    }.to_json
  end
  let(:timestamp) { Time.now.to_i }

  before do
    allow(Rails.application.credentials).to receive(:stripe_webhook_secret).and_return(secret)
  end

  def stripe_sig(body, secret, ts)
    signed = "#{ts}.#{body}"
    "t=#{ts},v1=#{OpenSSL::HMAC.hexdigest('SHA256', secret, signed)}"
  end

  it "enqueues the job for a valid event" do
    expect(StripeWebhookJob).to receive(:perform_later).once

    post "/webhooks/stripe",
         params:  payload,
         headers: { "Stripe-Signature" => stripe_sig(payload, secret, timestamp),
                    "Content-Type" => "application/json" }

    expect(response).to have_http_status(:ok)
  end

  it "returns 400 for an invalid signature" do
    post "/webhooks/stripe",
         params:  payload,
         headers: { "Stripe-Signature" => "t=#{timestamp},v1=invalidsignature",
                    "Content-Type" => "application/json" }

    expect(response).to have_http_status(:bad_request)
  end
end

Compute the expected HMAC signature yourself in the test using the same algorithm. You only need a live Stripe sandbox for end-to-end integration tests; controller behavior is fully testable without it.

Routes and Queue Configuration

# config/routes.rb
Rails.application.routes.draw do
  namespace :webhooks do
    post "/stripe", to: "stripe#create"
    post "/github", to: "github#create"
  end
end

Don’t put webhook endpoints under your API namespace or behind authentication middleware. They need raw body access and no CSRF protection. Namespacing under webhooks/ keeps them visible and isolated.

Dedicate a queue to webhook jobs. They have different latency requirements from your regular background work:

# config/solid_queue.yml
dispatchers:
  - polling_interval: 0.1
    batch_size: 500

workers:
  - queues: webhooks
    threads: 5
  - queues: default,mailers
    threads: 3

Separate workers mean a burst of webhook traffic can’t starve your regular jobs, and vice versa. Three to five webhook workers is usually adequate — events are short-lived, they do a database write and a handful of side effects.

Monitoring Failed Events

The status column on webhook_events is your audit trail. Periodically check for failures:

# In a Rake task, admin console, or scheduled job
WebhookEvent.failed.order(created_at: :desc).limit(50).each do |event|
  Rails.logger.error(
    "[webhook] #{event.provider}/#{event.event_type} (#{event.event_id}): #{event.error_message}"
  )
end

You can also surface this in an admin dashboard, or set up an alert when failed count crosses a threshold. The payload column retains the original event data, so manual reprocessing is always possible:

event = WebhookEvent.find_by(event_id: "evt_1234")
StripeEventHandler.handle(event.event_type, event.payload)
event.update!(status: :processed, error_message: nil)

Webhook handling is one of those things every Rails app eventually needs and almost everyone gets wrong the first time. The triple welcome email is a rite of passage. With HMAC verification, a database-backed idempotency layer, and a dedicated background queue, you’ll never send three of them again.

For more on background job patterns, see the Rails background jobs guide with Solid Queue and Sidekiq. For concurrent writes and locking patterns, zero-downtime database migrations covers the same class of problems.

Building integrations with Stripe, GitHub, or a dozen other providers? TTB Software has been shipping Rails to production for nineteen years. We’ve processed more webhooks than we can count — and fixed more duplicate-email bugs too.

Frequently Asked Questions

How do I verify Stripe webhook signatures in Rails?

Use Stripe::Webhook.construct_event with the raw request body, the Stripe-Signature header value, and your webhook signing secret from the Stripe dashboard. Always read request.body.read before Rails parses the body. Set skip_before_action :verify_authenticity_token on the controller. Rescue Stripe::SignatureVerificationError and return HTTP 400.

What does idempotent webhook processing mean in Rails?

It means processing the same event ID twice produces the same outcome as processing it once — no duplicate emails, no double charges, no extra records. Implement it by creating a webhook_events table with a unique index on (provider, event_id), inserting a record before processing, and catching ActiveRecord::RecordNotUnique to silently skip duplicates.

Should Rails webhook endpoints process events synchronously or in background jobs?

Always in background jobs. Most providers retry if you don’t respond within five to thirty seconds. Synchronous processing risks timeouts under load, and any transient failure triggers a retry. Respond with HTTP 200 immediately after enqueuing, and let the job handle the actual logic.

How do I test Rails webhook controllers without hitting the live provider API?

Compute the HMAC signature yourself in the test — use the same algorithm the provider uses (typically SHA256 with a shared secret) and set the header manually. Unit and request tests can be fully self-contained. Reserve live sandbox tests for end-to-end acceptance scenarios.

#rails #webhooks #stripe #idempotency #background-jobs #security #hmac
R

About the Author

Roger Heykoop is a senior Ruby on Rails developer with 19+ years of Rails experience and 35+ years in software development. He specializes in Rails modernization, performance optimization, and AI-assisted development.

Get in Touch

Share this article

Need Expert Rails Development?

Let's discuss how we can help you build or modernize your Rails application with 19+ years of expertise

Schedule a Free Consultation