Every Rails app that takes Stripe webhooks has some version of this controller:
def create
payload = request.body.read
sig_header = request.env["HTTP_STRIPE_SIGNATURE"]
event = Stripe::Webhook.construct_event(payload, sig_header, ENV["STRIPE_SECRET"])
return head :ok if StripeEvent.exists?(stripe_id: event.id)
StripeEvent.create!(stripe_id: event.id, payload: payload)
HandleStripeEventJob.perform_later(event.id)
head :ok
rescue Stripe::SignatureVerificationError
head :unauthorized
rescue ActiveRecord::RecordNotUnique
head :ok
end
I've written this in at least four different Rails apps. I've seen it copied from the same three blog posts. And I've had it break in production — twice.
The problem with hand-rolling it
It looks fine. It's not fine.
The race condition. Stripe retries failed webhooks. Two retries can arrive within milliseconds of each other. Both hit StripeEvent.exists? before either has committed a row. Both return false. Both create a row. Now your customer.subscription.created handler has fired twice — you've created two subscriptions, charged the customer twice, sent two welcome emails.
The rescue ActiveRecord::RecordNotUnique at the bottom is the fix, but it's easy to miss, and it only works if you have a unique index on stripe_id. Most people add the check but forget the index.
The missing replay. Your handler has a bug. It's been failing silently for 6 hours. You've lost 40 events. You want to reprocess them. Your options are: query the raw payload from wherever you stored it, manually re-trigger the job for each one, and hope you got it right. There's no dashboard showing you what failed, no replay button, no error messages.
The tight coupling. The controller does too much — it verifies, deduplicates, stores, and enqueues, all inline. Every time you add a provider (Shopify, GitHub, Twilio), you copy-paste and adapt. It diverges immediately.
The inbox pattern
The solution is well-known in backend circles. Store every incoming webhook immediately, then process asynchronously. Deduplication happens at the storage layer, not the application layer. Processing failures are recoverable because the original payload is always there.
Every blog post about this teaches you to build it yourself. RailsConf 2023 had a whole workshop on it. AppSignal wrote about it. They all show the same ~50 lines and send you on your way.
I got tired of writing those 50 lines. So I packaged them.
webhook_inbox
bundle add webhook_inbox
rails generate webhook_inbox:install
rails db:migrate
The generator creates the webhook_inbox_events table and an initializer stub.
Wire up your controller:
class StripeWebhooksController < ApplicationController
include WebhookInbox::Receiver
receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
def create
receive_webhook!
end
end
Register your handlers:
# config/initializers/webhook_inbox.rb
WebhookInbox.configure do |config|
config.on(:stripe, "customer.subscription.created") do |event|
CreateSubscriptionJob.perform_later(event.parsed_payload)
end
config.on(:stripe, "invoice.payment_failed") do |event|
NotifyPaymentFailedJob.perform_later(event.parsed_payload)
end
end
That's it. receive_webhook! runs the full pipeline:
- Verifies the Stripe signature →
401on failure - Inserts the event into the DB — the unique constraint on
[provider, event_id]is the deduplication mechanism, not application code - Enqueues
WebhookInbox::ProcessJobvia ActiveJob - Responds
200 OK
The race condition is gone. The DB unique constraint is enforced at the transaction level. Two simultaneous identical deliveries can't both succeed — one will hit ActiveRecord::RecordNotUnique and return 200 silently. No double processing.
Why the DB constraint, not application code
The critical design decision is putting deduplication in the database, not the controller.
add_index :webhook_inbox_events, [:provider, :event_id], unique: true
Application-level checks (exists? before create!) have a TOCTOU (time-of-check-time-of-use) race: two processes can both read false from exists? before either has written. Database constraints don't have this problem — they're enforced atomically at the transaction level by the DB engine.
The constraint works on SQLite, PostgreSQL, and MySQL. No Redis, no distributed locks, no external services.
Replay and visibility
Every event is stored with its full payload, status, attempt count, and any error message from failures.
WebhookInbox::Event.failed.each(&:retry!)
WebhookInbox::Event.for_provider(:stripe).where(event_type: "invoice.payment_failed")
event.status # => "failed"
event.error_message # => "RuntimeError: customer not found\n app/jobs/..."
event.attempts # => 3
event.retry! # → re-enqueues the handler job
Mount the dashboard in config/routes.rb:
mount WebhookInbox::Engine => "/webhook_inbox"
The dashboard shows all events with status badges, full JSON payload, and a replay button per event. When something goes wrong at 2am, you want to see exactly what failed and reprocess it from a browser — not dig through logs.
Testing
# spec/rails_helper.rb
require "webhook_inbox/rspec"
RSpec.describe "Stripe billing", type: :request do
it "creates a subscription on webhook" do
deliver_webhook(:stripe, "customer.subscription.created", payload: {
data: { object: { id: "sub_123", customer: "cus_456" } }
})
perform_enqueued_jobs
expect(Subscription.find_by(stripe_id: "sub_123")).to be_active
end
it "ignores duplicate deliveries" do
2.times do
deliver_webhook(:stripe, "customer.subscription.created",
event_id: "evt_fixed_id", payload: {})
end
expect(WebhookInbox::Event.count).to eq(1)
end
end
deliver_webhook signs the request with HMAC-SHA256 the same way Stripe does. The signature passes real verification — no mocking of the signature check, no bypassing the controller logic.
What about stripe_event?
stripe_event is a great event router — 14.5M downloads — and it's not what webhook_inbox replaces. stripe_event routes events to handlers. It has no storage, no deduplication, no replay.
If you use stripe_event, webhook_inbox sits underneath it as the storage and dedup layer. They're complementary.
One more thing: body rewinding
One sharp edge that burned me during development: Rails middleware can consume request.body before your controller sees it. If you read the body for signature verification, it's gone by the time you try to read it again for storage.
The fix is explicit: request.body.rewind before every read. The gem handles this internally — read_request_body rewinds before reading, and the raw body is passed explicitly to the provider adapter rather than re-reading from the request. It's boring plumbing but it has to be right.
Get it
bundle add webhook_inbox
rails generate webhook_inbox:install
rails db:migrate
GitHub: jibranusman95/webhook_inbox
If you hit something weird or have a use case I haven't thought of, open an issue. I read them all.
Top comments (0)