ruby 107 lines · 4 tabs

Idempotent Stripe Webhook Processing with a Unique Event Key in Rails

Shared by codesnips Aug 2026
4 tabs
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
4 files · ruby Explain with highlit

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

ruby
class CommentsController < ApplicationController
  before_action :set_post

  def create
    @comment = @post.comments.build(comment_params)

System test: asserting Turbo Stream responses

rails hotwire turbo
by codesnips 4 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 with a Unique Event Key in Rails — share card
Link copied