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
class StripeEvent < ApplicationRecord
STATUSES = %w[received processed failed].freeze
validates :stripe_event_id, presence: true, uniqueness: true
validates :status, inclusion: { in: STATUSES }
def self.record!(event)
create!(
stripe_event_id: event.id,
event_type: event.type,
payload: event.as_json
)
rescue ActiveRecord::RecordNotUnique
find_by!(stripe_event_id: event.id)
end
def processed?
status == "processed"
end
def mark_processed!
update!(status: "processed", processed_at: Time.current, last_error: nil)
end
def mark_failed!(error)
update!(status: "failed", last_error: error.message)
end
def payload_object
Stripe::Event.construct_from(payload)
end
end
class WebhooksController < ActionController::API
def stripe
payload = request.body.read
signature = request.env["HTTP_STRIPE_SIGNATURE"]
event = Stripe::Webhook.construct_event(
payload,
signature,
Rails.application.credentials.stripe[:webhook_secret]
)
record = StripeEvent.record!(event)
StripeEventJob.perform_later(record.id) unless record.processed?
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.warn("Rejected Stripe webhook: #{e.message}")
head :bad_request
end
end
class StripeEventJob < ApplicationJob
queue_as :webhooks
def perform(stripe_event_id)
record = StripeEvent.find(stripe_event_id)
return if record.processed?
event = record.payload_object
ActiveRecord::Base.transaction do
handle(event)
record.mark_processed!
end
rescue => e
record&.mark_failed!(e)
raise
end
private
def handle(event)
case event.type
when "checkout.session.completed"
fulfill_checkout(event.data.object)
when "invoice.payment_failed"
flag_past_due(event.data.object)
else
Rails.logger.info("Ignoring unhandled Stripe event #{event.type}")
end
end
def fulfill_checkout(session)
order = Order.find_by!(checkout_session_id: session.id)
order.fulfill! unless order.fulfilled?
end
def flag_past_due(invoice)
Subscription.find_by(stripe_id: invoice.subscription)&.mark_past_due!
end
end
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
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
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
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
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
module Api
module V1
class UsersController < BaseController
def show
user = User.includes(:profile).find(params[:id])
ETags for conditional requests and caching
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
Share this code
Here's the card — post it anywhere.