DEV Community

Jibran Usman
Jibran Usman

Posted on

The webhook inbox pattern: stop writing this controller by hand

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

That's it. receive_webhook! runs the full pipeline:

  1. Verifies the Stripe signature → 401 on failure
  2. Inserts the event into the DB — the unique constraint on [provider, event_id] is the deduplication mechanism, not application code
  3. Enqueues WebhookInbox::ProcessJob via ActiveJob
  4. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Mount the dashboard in config/routes.rb:

mount WebhookInbox::Engine => "/webhook_inbox"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)