class CreateWebhookEvents < ActiveRecord::Migration[7.1]
def change
create_table :webhook_events do |t|
t.string :event_id, null: false
t.string :source, null: false, default: "stripe"
t.string :event_type, null: false
t.string :status, null: false, default: "received"
t.jsonb :payload, null: false, default: {}
t.datetime :processed_at
t.text :last_error
t.timestamps
end
add_index :webhook_events, :event_id, unique: true
add_index :webhook_events, [:source, :status]
end
end
class WebhookEvent < ApplicationRecord
STATUSES = %w[received processed failed].freeze
validates :event_id, :event_type, presence: true
validates :status, inclusion: { in: STATUSES }
def self.record!(event)
create!(
event_id: event.id,
event_type: event.type,
payload: event.to_hash
)
rescue ActiveRecord::RecordNotUnique
nil
end
def unprocessed?
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
end
class StripeWebhooksController < ActionController::API
def create
event = verified_event
webhook_event = WebhookEvent.record!(event)
if webhook_event.nil?
# Duplicate delivery: ack so Stripe stops retrying.
return head :ok
end
ProcessStripeEventJob.perform_later(webhook_event.id)
head :ok
rescue JSON::ParserError, Stripe::SignatureVerificationError => e
Rails.logger.warn("Rejected Stripe webhook: #{e.message}")
head :bad_request
end
private
def verified_event
payload = request.body.read
signature = request.headers["Stripe-Signature"]
Stripe::Webhook.construct_event(
payload,
signature,
Rails.application.credentials.dig(:stripe, :webhook_secret)
)
end
end
class ProcessStripeEventJob < ApplicationJob
queue_as :webhooks
def perform(webhook_event_id)
webhook_event = WebhookEvent.find(webhook_event_id)
webhook_event.with_lock do
return unless webhook_event.unprocessed?
handle(webhook_event)
webhook_event.mark_processed!
end
rescue StandardError => e
webhook_event&.mark_failed!(e)
raise
end
private
def handle(webhook_event)
case webhook_event.event_type
when "charge.succeeded"
Billing::RecordCharge.call(webhook_event.payload)
when "customer.subscription.deleted"
Billing::CancelSubscription.call(webhook_event.payload)
else
Rails.logger.info("Ignoring unhandled event #{webhook_event.event_type}")
end
end
end
Webhook providers like Stripe guarantee at-least-once delivery, which means the same event can arrive two, three, or more times — on retries, timeouts, or network hiccups. Processing a charge.succeeded event twice could double-credit an account or send duplicate emails. The reliable defense is idempotency keyed on the provider's own event id, and this snippet shows how that is enforced end to end in a Rails app.
The CreateWebhookEvents migration establishes the backbone: a webhook_events table with a unique index on event_id. That database constraint is the real source of truth — even if two requests race and both pass an application-level check, only one INSERT can win. A status column and processed_at timestamp track the lifecycle so replays and dead events are distinguishable.
In WebhookEvent model, record! uses create! inside a rescue ActiveRecord::RecordNotUnique block. When the unique index rejects a duplicate, the method returns nil instead of raising, signaling "already seen." mark_processed! and mark_failed! transition the row, and unprocessed? guards the worker against double execution. Treating the row as a small state machine keeps the semantics explicit.
StripeWebhooksController verifies the signature with Stripe::Webhook.construct_event before trusting anything — an unsigned payload is rejected with 400. It then calls WebhookEvent.record! and, crucially, returns 200 even when the event was already recorded. Acknowledging duplicates quickly stops Stripe from retrying and hammering the endpoint. Only genuinely new events get enqueued to ProcessStripeEventJob.
The ProcessStripeEventJob re-checks unprocessed? under a with_lock block so that if the job itself is retried by Sidekiq, the business logic runs at most once. The lock serializes concurrent workers on the same row, and mark_processed! inside the transaction makes the state flip atomic with the side effect.
The key trade-off is that idempotency is enforced at the dedupe layer, not the effect layer — the handler still must be written so that its work is safe to attempt once per unique event. Storing the raw payload also lets failed events be replayed later without re-fetching from Stripe. This pattern generalizes to any at-least-once source: queues, retried jobs, or third-party callbacks.
Related snips
class CommentsController < ApplicationController
before_action :set_post
def create
@comment = @post.comments.build(comment_params)
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.