ruby 108 lines · 4 tabs

Idempotent Stripe Webhook Processing in Rails with a Durable Event Log

Shared by codesnips Jul 2026
4 tabs
class CreateStripeEvents < ActiveRecord::Migration[7.1]
  def change
    create_table :stripe_events do |t|
      t.string :stripe_event_id, null: false
      t.string :event_type, null: false
      t.string :status, null: false, default: "received"
      t.jsonb :payload, null: false, default: {}
      t.text :last_error
      t.datetime :processed_at
      t.timestamps
    end

    add_index :stripe_events, :stripe_event_id, unique: true
    add_index :stripe_events, :status
  end
end
4 files · ruby Explain with highlit

Stripe delivers webhooks with at-least-once semantics, so the same event can arrive more than once, out of order, and sometimes while an earlier delivery is still in flight. Naively handling events inline means a duplicate delivery can double-fulfill an order or credit an account twice. This snippet shows the durable-log pattern that makes processing exactly-once in effect even though delivery is at-least-once.

The create_stripe_events migration defines the table that anchors the whole approach. The stripe_event_id column carries a unique index, which turns idempotency into a database invariant rather than an application check: a duplicate insert simply fails. A status column tracks the lifecycle (received, processed, failed), and payload stores the raw JSON so processing can be retried or replayed without re-fetching from Stripe.

StripeEvent model wraps that table. record! uses create and rescues ActiveRecord::RecordNotUnique, returning the existing row when the event id has already been seen. This is the crucial move — the unique index does the deduplication, and the rescue converts the race into a normal return value. mark_processed! and mark_failed! advance the state machine, and the payload_object helper rebuilds a typed Stripe::Event for downstream code.

WebhooksController is intentionally thin. It verifies the signature with Stripe::Webhook.construct_event, which both authenticates the payload and guards against replayed bodies. Verification failures return 400 so Stripe stops retrying a malformed request. On success it calls record! and enqueues StripeEventJob, then responds 200 immediately — the endpoint acknowledges receipt fast and defers real work, so a slow handler never causes Stripe to time out and retry.

StripeEventJob does the actual fulfillment. It short-circuits if the event is already processed, wraps the handler in a transaction, and dispatches on event.type. Business methods like fulfill_checkout must themselves be idempotent, since a job can be retried after a partial failure. The combination of a unique index, an explicit status, and asynchronous processing yields a system that tolerates duplicates, retries, and crashes without corrupting payment state — the standard shape for any at-least-once integration, not just Stripe.


Related snips

ruby
require 'application_system_test_case'

class TasksTest < ApplicationSystemTestCase
  test 'deleting a task removes it from the list' do
    task = tasks(:one)

System test: asserting Turbo Stream responses

rails hotwire turbo
by Henry Kim 2 tabs
ruby
class Post < ApplicationRecord
  belongs_to :author, class_name: 'User'
  has_many :comments, dependent: :destroy

  scope :published, -> { where.not(published_at: nil).where('published_at <= ?', Time.current) }
  scope :draft, -> { where(published_at: nil) }

ActiveRecord scopes for reusable query logic

rails activerecord patterns
by Alex Kumar 1 tab
ruby
timestamp = request.headers.fetch('X-Signature-Timestamp')
signature = request.headers.fetch('X-Signature')
payload = request.raw_post

data = "#{timestamp}.#{payload}"
expected = OpenSSL::HMAC.hexdigest('SHA256', ENV.fetch('WEBHOOK_SECRET'), data)

HMAC signed API requests for webhook and partner integrity

hmac api-signing webhooks
by Kai Nakamura 2 tabs
typescript
export interface RetryOptions {
  retries: number;
  baseMs: number;
  maxMs: number;
  signal?: AbortSignal;
  onRetry?: (attempt: number, delay: number, err: unknown) => void;

Exponential backoff with jitter for retries

typescript reliability retry
by codesnips 2 tabs
ruby
module Api
  module V1
    class UsersController < BaseController
      def show
        user = User.includes(:profile).find(params[:id])

ETags for conditional requests and caching

rails caching http-caching
by Alex Kumar 1 tab
ruby
class PostsController < ApplicationController
  def index
    @posts = Post.includes(:author)
                 .order(created_at: :desc)
                 .page(params[:page])
                 .per(10)

Turbo Frames: infinite scroll with lazy-loading frame

rails turbo hotwire
by codesnips 4 tabs

Share this code

Here's the card — post it anywhere.

Idempotent Stripe Webhook Processing in Rails with a Durable Event Log — share card
Link copied