LaunchKit

Stripe webhooks in Rails, and the idempotency the database owes you

September 11, 2026

A Stripe webhook endpoint is the strangest controller in a Rails app. It has no session, no signed cookie, no CSRF token and no authenticated user, and it is nevertheless allowed to grant paid access to your product. The billing hub explains why that endpoint has to exist at all; this page is about making it safe.

Everything it is allowed to do rests on one check.

The signature is the only trust anchor

The endpoint is public. Anyone can POST to it, and a forged payload that says "this customer paid" is trivial to write. What stops it is the Stripe webhook signature: a header, Stripe-Signature, holding an HMAC that Stripe computed over the exact bytes of the request body using a secret only the two of you hold.

class WebhooksController < ApplicationController
  allow_unauthenticated_access
  skip_forgery_protection

  def stripe
    Billing::Webhooks::Handler.new(verified_event).call
    head :ok
  rescue JSON::ParserError, Stripe::SignatureVerificationError
    head :bad_request
  end

  private

  def verified_event
    Stripe::Webhook.construct_event(
      request.body.read,
      request.headers["Stripe-Signature"],
      AppConfig.stripe_webhook_secret
    )
  end
end

Both opt-outs at the top are deliberate and neither is a shortcut. allow_unauthenticated_access because Stripe has no account here, and skip_forgery_protection because CSRF protection defends a browser session that this request does not have. The signature replaces both.

Why the raw body, and not the parsed params

request.body.read rather than params, and the distinction is not stylistic. The signature was computed over the exact bytes Stripe sent. Parse that JSON and re-serialise it and you may get identical data with different bytes: a reordered key, a different float rendering, a dropped space. The data is the same and the HMAC is not, so verification fails with no clue as to why.

Read the body first, verify, and only then work with the parsed event that construct_event returns.

Answering 400 rather than letting it raise

A bad signature is not a server error, and the status code you return decides Stripe's behaviour.

A 500 tells Stripe the delivery failed and to try again, so a forged request becomes a retry loop you serve indefinitely. A 400 tells Stripe the request itself was unacceptable, and it stops. The rescue covers Stripe::SignatureVerificationError and JSON::ParserError together because both mean the same thing from your side: this payload is not something Stripe sent.

Stripe retries, so the handler runs more than once

Stripe retries until it receives a 2xx. That schedule runs for days, which means a handler that grants a month of access will grant it repeatedly unless something stops it, and a handler that records a transaction will record it several times.

There is a second, less obvious source of duplication. Your handler can succeed, and the response can be lost on the way back. Stripe never sees the 2xx, and delivers again. No bug on either side, the same event twice.

So rails webhook idempotency is not a refinement for later. It is the condition under which the endpoint is correct at all.

Where the guarantee actually lives

The tempting implementation is a StripeEvent model with validates :stripe_id, uniqueness: true. It looks right and it does not hold, because a Rails uniqueness validation is a SELECT followed by an INSERT. Two concurrent deliveries of the same event both run the SELECT, both find nothing, and both insert.

The guarantee belongs to the database:

add_index :stripe_events, :stripe_id, unique: true
class StripeEvent < ApplicationRecord
  def self.process_once(id:, type:)
    transaction(requires_new: true) do
      create!(stripe_id: id, event_type: type)
      yield
    end
  rescue ActiveRecord::RecordNotUnique
    false
  end
end

The insert is attempted first and the work runs only if it succeeded. A second delivery hits the index, raises ActiveRecord::RecordNotUnique, and returns false without running anything. No check-then-act window exists, because there is no check: the database decides.

The savepoint, and why the marker rolls back with the work

requires_new: true is the part most implementations leave out, and it is what separates a retryable failure from a silently swallowed one.

It opens a real savepoint, so the marker and the work commit or roll back together. If the handler raises halfway through granting access, the StripeEvent row disappears with it, and Stripe's next delivery finds no marker and gets a genuine second attempt.

Without the savepoint the marker can survive a failed handler. The event is then recorded as processed, the work never happened, and every retry Stripe sends is refused by the marker. The customer has paid and has nothing, and the logs say the event was handled.

Routing an event to exactly one handler

HANDLERS = {
  "checkout.session.completed" => CheckoutSessionCompleted,
  "customer.subscription.created" => SubscriptionUpserted,
  "customer.subscription.updated" => SubscriptionUpserted,
  "customer.subscription.deleted" => SubscriptionDeleted,
  "payment_intent.succeeded" => PaymentIntentSucceeded
}.freeze

def call
  handler_class = HANDLERS[event.type]
  return unless handler_class

  StripeEvent.process_once(id: event.id, type: event.type) do
    handler_class.new(event.data.object).call
  end
end

An unknown event type returns early and answers 200. That is deliberate: Stripe sends event types you never subscribed to and adds new ones over time, and raising on them would turn Stripe's product roadmap into your incident pager.

Note that created and updated share one handler. Stripe does not promise ordering, so treating the two as different instructions means the older snapshot can overwrite the newer one. Treating both as "here is the subscription as it stands" removes the ordering assumption entirely.

A second layer where the first one is not enough

process_once protects against the same event arriving twice. It does not protect against two different events describing the same payment, which Stripe will also do.

So the payment handler keys on the Stripe object rather than on the event:

user.transactions.find_or_create_by!(stripe_id: payment_intent.id) do |transaction|
  # ...
end

Two layers, on two different keys, because they answer two different questions. One asks "have I seen this delivery", the other asks "do I already have this payment".

What this codebase does around Stripe webhooks

The endpoint verifies before it parses, answers 400 on anything unsigned, and hands a verified event to a handler table that ignores what it does not know. Idempotency is delegated once, at the routing layer, so no individual handler has to remember it, and the savepoint means a handler that raises leaves the event retryable rather than marked done. The subscription handlers upsert rather than branch on event type, and the payment handler carries its own uniqueness on the Stripe id, for the case the event ledger cannot see.

← All Stripe billing in Rails articles