<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Stripe &#8211; Other Things</title>
	<atom:link href="https://blog.adamzolo.com/category/stripe/feed/" rel="self" type="application/rss+xml" />
	<link>https://blog.adamzolo.com</link>
	<description>Blog about Things by Adam Zolotarev</description>
	<lastBuildDate>Thu, 12 Feb 2026 11:37:48 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	<generator>https://wordpress.org/?v=6.9.1</generator>
	<item>
		<title>Idempotent Stripe Webhooks</title>
		<link>https://blog.adamzolo.com/idempotent-stripe-webhooks/</link>
					<comments>https://blog.adamzolo.com/idempotent-stripe-webhooks/#respond</comments>
		
		<dc:creator><![CDATA[Adam Zolo]]></dc:creator>
		<pubDate>Thu, 12 Feb 2026 11:37:48 +0000</pubDate>
				<category><![CDATA[Rails]]></category>
		<category><![CDATA[Stripe]]></category>
		<guid isPermaLink="false">https://blog.adamzolo.com/?p=1097</guid>

					<description><![CDATA[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&#8230;<p><a class="more-link" href="https://blog.adamzolo.com/idempotent-stripe-webhooks/" title="Continue reading &#8216;Idempotent Stripe Webhooks&#8217;">Continue reading <span class="meta-nav">&#8594;</span></a></p>]]></description>
										<content:encoded><![CDATA[
<h1 class="wp-block-heading">How to Implement Idempotent Stripe Webhooks in Rails</h1>



<p>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.</p>



<p>The fix is idempotency: making your webhook handler safe to call multiple times with the same event.</p>



<h2 class="wp-block-heading">The Problem</h2>



<p>Every Stripe event has a unique ID like <code>evt_1abc123</code>. Stripe guarantees this ID is unique, but your endpoint has no such guarantee about delivery. From the <a href="https://docs.stripe.com/webhooks#handle-duplicate-events">Stripe docs</a>:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p>Webhook endpoints might occasionally receive the same event more than once. You can guard against duplicated event receipt by making your event processing idempotent.</p>
</blockquote>



<p>Without protection, a retried <code>invoice.paid</code> event could credit a user&#8217;s account twice or trigger duplicate emails.</p>



<h2 class="wp-block-heading">The Solution</h2>



<p>Track every processed event ID in your database. Before handling an event, check if you&#8217;ve already seen it. If yes, skip it.</p>



<h3 class="wp-block-heading">Step 1: Create the Table</h3>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: ruby; title: ; notranslate">
# db/migrate/xxx_create_stripe_webhook_events.rb
class CreateStripeWebhookEvents &lt; ActiveRecord::Migration&#x5B;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
</pre></div>


<p>The unique index on <code>stripe_event_id</code> is the key. Even if two requests arrive simultaneously, the database constraint guarantees only one INSERT succeeds.</p>



<h3 class="wp-block-heading">Step 2: Create the Model</h3>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: ruby; title: ; notranslate">
# app/models/stripe_webhook_event.rb
class StripeWebhookEvent &lt; 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
</pre></div>


<p><code>process</code> returns <code>true</code> if this is a new event, <code>false</code> if it&#8217;s a duplicate. We rescue both exceptions because:</p>



<ul class="wp-block-list">
<li><code>RecordNotUnique</code> — the DB unique constraint catches concurrent duplicate inserts</li>



<li><code>RecordInvalid</code> — the model-level uniqueness validation catches sequential duplicates</li>
</ul>



<h3 class="wp-block-heading">Step 3: Use It in Your Webhook Handler</h3>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
# app/services/stripe_webhook_handler.rb
def process!
  event = verify_signature!

  unless StripeWebhookEvent.process(event.id, event_type: event.type)
    Rails.logger.info(&quot;Skipping duplicate webhook: #{event.id}&quot;)
    return { status: &quot;success&quot;, duplicate: true }
  end

  handle_event(event)
end
</pre></div>


<p>The check goes right after signature verification and before any business logic. If it&#8217;s a duplicate, we return a success response (so Stripe doesn&#8217;t keep retrying) and skip processing.</p>



<h3 class="wp-block-heading">Step 4: Return 200 for Duplicates</h3>



<p>This is important — always return a 2xx status for duplicates:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: ruby; title: ; notranslate">
# app/controllers/webhooks_controller.rb
def stripe
  handler = StripeWebhookHandler.new(
    payload: request.body.read,
    signature: request.env&#x5B;&quot;HTTP_STRIPE_SIGNATURE&quot;]
  )

  result = handler.process!
  render json: result, status: :ok
rescue StripeWebhookHandler::WebhookError =&gt; e
  render json: { error: e.message }, status: :bad_request
end
</pre></div>


<p>If you return an error for a duplicate, Stripe will keep retrying — which is the opposite of what you want.</p>



<h2 class="wp-block-heading">Cleanup</h2>



<p>Over time, the <code>stripe_webhook_events</code> table will grow. Add a periodic cleanup job to prune old records:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
# Keep 90 days of webhook history
StripeWebhookEvent.where(&quot;created_at &amp;lt; ?&quot;, 90.days.ago).delete_all
</pre></div>


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



<h2 class="wp-block-heading">Summary</h2>



<ol class="wp-block-list">
<li>Create a table with a unique index on <code>stripe_event_id</code></li>



<li>Attempt an INSERT before processing — if it fails, it&#8217;s a duplicate</li>



<li>Always return 200 for duplicates so Stripe stops retrying</li>



<li>The database constraint handles race conditions that application-level checks can&#8217;t</li>
</ol>



<p></p>
]]></content:encoded>
					
					<wfw:commentRss>https://blog.adamzolo.com/idempotent-stripe-webhooks/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>
