Idempotent Stripe Webhooks

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 inserts
  • RecordInvalid — 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 &lt; ?", 90.days.ago).delete_all

Stripe retries happen within hours, not months, so 90 days is more than enough.

Summary

  1. Create a table with a unique index on stripe_event_id
  2. Attempt an INSERT before processing — if it fails, it’s a duplicate
  3. Always return 200 for duplicates so Stripe stops retrying
  4. The database constraint handles race conditions that application-level checks can’t

Leave a Reply

Your email address will not be published. Required fields are marked *


This site uses Akismet to reduce spam. Learn how your comment data is processed.