Rails Webhook Processing: Signature Verification, Idempotency and Background Delivery
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:
- Authentication: Did this actually come from Stripe, or is someone crafting fake events?
- Idempotency: What happens when the same event arrives twice — or ten times?
- 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.
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 TouchRelated Articles
Ruby on Rails Feature Flags: Complete Guide with Flipper, Rollout and Custom Redis Implementation
April 13, 2026
Rails Concerns: When They Clean Up Code and When They Create Hidden Complexity
March 13, 2026
Rails 8 Multiple Databases: Read Replicas, Sharding, and Automatic Role Switching
March 12, 2026
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