How to Implement Idempotent Stripe Webhooks in Rails
Stripe can send the same webhook event more than once. Network timeouts, retries, and infrastructure hiccups all mean your endpoint might process the same event twice, charging a customer double, creating duplicate subscriptions, or corrupting your data.
The fix is idempotency: making your webhook handler safe to call multiple times with the same event.
The Problem
Every Stripe event has a unique ID like evt_1abc123. Stripe guarantees this ID is unique, but your endpoint has no such guarantee about delivery. From the Stripe docs:
Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipt by making your event processing idempotent.
Without protection, a retried invoice.paid event could credit a user’s account twice or trigger duplicate emails.
The Solution
Track every processed event ID in your database. Before handling an event, check if you’ve already seen it. If yes, skip it.
Step 1: Create the Table
# db/migrate/xxx_create_stripe_webhook_events.rb
class CreateStripeWebhookEvents < ActiveRecord::Migration[8.1]
def change
create_table :stripe_webhook_events do |t|
t.string :stripe_event_id, null: false
t.string :event_type
t.datetime :processed_at
t.timestamps
end
add_index :stripe_webhook_events, :stripe_event_id, unique: true
end
end
The unique index on stripe_event_id is the key. Even if two requests arrive simultaneously, the database constraint guarantees only one INSERT succeeds.
Step 2: Create the Model
# app/models/stripe_webhook_event.rb
class StripeWebhookEvent < ApplicationRecord
validates :stripe_event_id, presence: true, uniqueness: true
def self.process(stripe_event_id, event_type:)
create!(
stripe_event_id: stripe_event_id,
event_type: event_type,
processed_at: Time.current
)
true
rescue ActiveRecord::RecordNotUnique, ActiveRecord::RecordInvalid
false
end
end
process returns true if this is a new event, false if it’s a duplicate. We rescue both exceptions because:
RecordNotUnique— the DB unique constraint catches concurrent duplicate insertsRecordInvalid— the model-level uniqueness validation catches sequential duplicates
Step 3: Use It in Your Webhook Handler
# app/services/stripe_webhook_handler.rb
def process!
event = verify_signature!
unless StripeWebhookEvent.process(event.id, event_type: event.type)
Rails.logger.info("Skipping duplicate webhook: #{event.id}")
return { status: "success", duplicate: true }
end
handle_event(event)
end
The check goes right after signature verification and before any business logic. If it’s a duplicate, we return a success response (so Stripe doesn’t keep retrying) and skip processing.
Step 4: Return 200 for Duplicates
This is important — always return a 2xx status for duplicates:
# app/controllers/webhooks_controller.rb
def stripe
handler = StripeWebhookHandler.new(
payload: request.body.read,
signature: request.env["HTTP_STRIPE_SIGNATURE"]
)
result = handler.process!
render json: result, status: :ok
rescue StripeWebhookHandler::WebhookError => e
render json: { error: e.message }, status: :bad_request
end
If you return an error for a duplicate, Stripe will keep retrying — which is the opposite of what you want.
Cleanup
Over time, the stripe_webhook_events table will grow. Add a periodic cleanup job to prune old records:
# Keep 90 days of webhook history
StripeWebhookEvent.where("created_at < ?", 90.days.ago).delete_all
Stripe retries happen within hours, not months, so 90 days is more than enough.
Summary
- Create a table with a unique index on
stripe_event_id - Attempt an INSERT before processing — if it fails, it’s a duplicate
- Always return 200 for duplicates so Stripe stops retrying
- The database constraint handles race conditions that application-level checks can’t