Production-ready transactional inbox for Rails webhooks. Two lines.
[Screenshot: /webhook_inbox dashboard — table of Stripe events with green "processed", red "failed", yellow "pending" badges. One row expanded to show full JSON payload and a "Replay" button.]
Every Rails app that accepts webhooks has written 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
endAnd every Rails app has had a production incident when it broke.
Here's the same thing with webhook_inbox:
class StripeWebhooksController < ApplicationController
include WebhookInbox::Receiver
receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
def create
receive_webhook!
end
endSignature verification, deduplication, async processing, replay, and a dashboard. All included. No Redis, no extra services, no copy-paste.
Deduplication — DB unique constraint on [provider, event_id]. Two identical deliveries arriving simultaneously? Both pass the exists? check? Doesn't matter. The constraint is enforced at the database level. Duplicates silently return 200.
Async processing — events are stored first, then processed via your existing job queue. Stripe gets its 200 immediately. Your handler can take as long as it needs.
Replay — any event can be re-run from the dashboard or via event.retry!. Debug handlers, recover from bugs, reprocess failed deliveries.
Dashboard — /webhook_inbox shows every event, its status, full JSON payload, error details, and a replay button. Protected by a configurable auth lambda.
RSpec helpers — deliver_webhook(:stripe, "event.type", payload: {}) posts a correctly-signed request in your tests. No mocking, no fixtures.
# Gemfile
gem "webhook_inbox"bundle install
rails generate webhook_inbox:install
rails db:migrateMount the dashboard:
# config/routes.rb
post "/webhooks/stripe", to: "stripe_webhooks#create"
mount WebhookInbox::Engine => "/webhook_inbox"# config/initializers/webhook_inbox.rb
WebhookInbox.configure do |config|
# Register handlers — block receives a WebhookInbox::Event object
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
# Catch-all — runs for every Stripe event with no exact match
config.on(:stripe, "*") do |event|
Rails.logger.info "[webhook] Unhandled #{event.event_type}"
end
# Queue name (default: "webhooks")
config.queue_name = "webhooks"
# Dashboard auth — return truthy to allow access. Required in production.
config.dashboard_auth = ->(controller) { controller.current_user&.admin? }
endclass StripeWebhooksController < ApplicationController
include WebhookInbox::Receiver
receive_from :stripe, secret: -> { ENV["STRIPE_WEBHOOK_SECRET"] }
def create
receive_webhook!
# receive_webhook! always responds — nothing needed after it
end
endreceive_webhook! runs the full pipeline:
- Verify Stripe signature →
401on failure - Insert event into DB → silent
200on duplicate (race-condition-safe) - Enqueue
WebhookInbox::ProcessJob - Respond
200 OK
# Query
WebhookInbox::Event.pending.count
WebhookInbox::Event.failed.each(&:retry!)
WebhookInbox::Event.for_provider(:stripe).where(event_type: "invoice.payment_failed")
# Event object passed to handlers
event.provider # => "stripe"
event.event_id # => "evt_1ABC..."
event.event_type # => "customer.subscription.created"
event.parsed_payload # => Hash (parsed from stored JSON)
event.attempts # => 1
event.status # => "pending" | "processing" | "processed" | "failed"
event.error_message # => "RuntimeError: handler exploded\n..." (on failure)
# Replay a specific event
WebhookInbox::Event.find_by(event_id: "evt_1ABC...").retry!POST /webhooks/stripe
│
▼
WebhookInbox::Receiver
│
├── 1. Verify signature (401 on failure)
├── 2. INSERT webhook_inbox_events (200 silent on duplicate)
├── 3. Enqueue ProcessJob
└── 4. Respond 200 OK
│
▼ (async, via your queue)
WebhookInbox::ProcessJob
│
├── 1. Find event, mark: processing
├── 2. Look up handlers for [provider, event_type]
├── 3. Call each handler block
├── 4a. Success → status: processed, processed_at: now
└── 4b. Failure → status: failed, error_message stored, re-raise for retry
# spec/rails_helper.rb
require "webhook_inbox/rspec"
# In request specs (auto-included)
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
enddeliver_webhook signs the request using the same HMAC-SHA256 scheme as Stripe's live webhooks. The signature will pass receive_webhook! verification without any mocking. Pass secret: to match your test initializer (default: "test_secret").
Mount in config/routes.rb:
mount WebhookInbox::Engine => "/webhook_inbox"Configure auth in the initializer:
config.dashboard_auth = ->(controller) { controller.current_user&.admin? }In development, the dashboard is open when dashboard_auth is not set. In production, it blocks with a clear error if auth is not configured.
The dashboard shows:
- All events with status badges (pending / processing / processed / failed)
- Filter by status or provider
- Full JSON payload for each event
- Error message and stack trace on failures
- Replay button — re-enqueues the handler job
stripe_event is a great event router — 14.5M downloads. It dispatches to handlers. That's all it does.
It has no storage, no deduplication, no replay, no dashboard. You still write the controller, the dedup migration, the job, and the retry logic.
webhook_inbox handles the layer below: receive, store, deduplicate, process, replay. They're complementary. If you already use stripe_event for routing, webhook_inbox can sit underneath it as the storage and dedup layer.
create_table :webhook_inbox_events do |t|
t.string :provider, null: false
t.string :event_id, null: false
t.string :event_type
t.text :payload, null: false, default: "{}"
t.string :status, null: false, default: "pending"
t.integer :attempts, null: false, default: 0
t.text :error_message
t.datetime :processed_at
t.timestamps
end
add_index :webhook_inbox_events, [:provider, :event_id], unique: true
add_index :webhook_inbox_events, :status
add_index :webhook_inbox_events, :created_atpayload is text (not jsonb) for cross-database compatibility — identical behavior on SQLite, MySQL, and PostgreSQL.
- Ruby >= 3.1
- Rails >= 7.0
- ActiveRecord, ActiveJob, ActionController
I built this myself — which means it works great for the cases I thought of, and probably has rough edges for the ones I didn't. If you hit something weird, open an issue. I read them all and respond fast.
Want to fix something or add a feature? Send a PR. No CLA, no process overhead, no committee review. If the tests pass and the change makes sense, it's getting merged. I'm one person and I genuinely appreciate the help — you can take this further than I can alone.
Not sure where to start? Look for good first issue labels, or just open an issue and ask.
git clone https://github.com/jibranusman95/webhook_inbox
cd webhook_inbox
bundle install
bundle exec rspec # all green? you're good to go
bundle exec rubocop # no new offensesSee CONTRIBUTING.md for full guidelines.
Everyone who's made this better:
| Gem | What it does |
|---|---|
| llm_cassette | VCR for LLMs — streaming-aware cassette recorder for OpenAI and Anthropic |
| turbo_presence | Figma-style live cursors, avatar stacks, and typing indicators for Rails — one line |
| http_decoy | A real Rack server that runs inside your RSpec tests — test HTTP contracts, not stubs |
| promptscrub | PII redaction middleware for LLM calls |
| agent_jail | Fork-based sandbox for LLM tool calls — timeout, memory limit, and filesystem restrictions |
MIT. See LICENSE.