Stripe Webhooks in Rails: The Gotchas Nobody Warns You About
Stripe's webhook documentation is excellent. It walks you through signature verification, event types, and retry behavior. You'll have a working webhook endpoint in twenty minutes.
Then you'll spend the next two weeks debugging the edge cases it doesn't mention.
We run a Rails store that processes real Stripe payments. Not a SaaS with subscriptions — actual e-commerce with shopping carts, shipping addresses, and fulfillment to print-on-demand suppliers. Every gotcha in this post comes from a production incident or a near-miss we caught in code review.
Gotcha 1: You Have Two Paths Competing to Complete the Order
Here's the flow nobody warns you about. Your frontend calls stripe.confirmCardPayment(), gets a success response, and POSTs to your /checkout/:id/confirm endpoint. Your server retrieves the PaymentIntent, sees status == "succeeded", and marks the order complete.
Meanwhile, Stripe fires a checkout.session.completed webhook to your /webhooks/stripe/receive endpoint. It also tries to mark the order complete.
Both paths send a confirmation email. Both trigger fulfillment. If they race, the customer gets two emails and you submit two print orders for the same items.
The standard advice is "use webhooks as the source of truth." That's correct in theory. In practice, your frontend needs to know the payment succeeded right now to show the confirmation page. You can't tell the customer "we'll email you when the webhook arrives."
Our solution: the confirmation endpoint does the real work — marks payment complete, sends the email, triggers fulfillment. The webhook acts as a safety net for cases where the frontend never calls confirm (browser crash, network timeout, user closes tab). Both paths check payment_status before acting:
# In the webhook handler
return unless session.payment_status == "paid"
order = Order.find_by(id: order_id)
return unless order # Already handled or deleted
# In the confirmation endpoint
intent = Stripe::PaymentIntent.retrieve(payment_intent_id)
return unless intent.status == "succeeded"
The webhook handles the case confirm never fires. The confirm endpoint handles the happy path. Neither assumes it's the only one running.
Gotcha 2: find_or_initialize_by Is Not Idempotent Enough
For our digital purchases, we use a pattern that looks safe:
purchase = DigitalPurchase.find_or_initialize_by(stripe_session_id: session.id)
return if purchase.persisted?
If the webhook fires twice (Stripe retries on timeout), the second call finds the existing record and returns early. Clean.
Except find_or_initialize_by doesn't hold a lock. Two concurrent webhook deliveries can both call find_or_initialize_by, both get unpersisted records, and both try to save!. You need the database uniqueness constraint as the true guard:
rescue ActiveRecord::RecordNotUnique
Rails.logger.info("Purchase already exists for session #{session.id}")
end
The find_or_initialize_by check prevents most duplicates. The unique constraint catches the race condition. The rescue keeps it from raising. You need all three layers — the application check, the database constraint, and the exception handler. Skip any one and you'll eventually get duplicate records or 500 errors on webhook retries.
Gotcha 3: Getting Stripe Fees Requires Three API Calls
After payment completes, we capture the Stripe processing fee for cost tracking. The fee lives on the BalanceTransaction. To reach it:
intent = Stripe::PaymentIntent.retrieve(payment_reference)
charge = Stripe::Charge.retrieve(intent.latest_charge)
balance_transaction = Stripe::BalanceTransaction.retrieve(charge.balance_transaction)
fee_cents = balance_transaction.fee
Three sequential API calls. Each can fail independently. We tried using expand to do it in one call — Stripe::PaymentIntent.retrieve(id, expand: ['latest_charge.balance_transaction']) — but the Stripe Ruby gem (v18) doesn't handle the array parameter correctly. It throws a type error.
The fix: make it an async job with retry and partial-failure tolerance.
class CaptureOrderCostsJob < ApplicationJob
retry_on StandardError, wait: :polynomially_longer, attempts: 5
def perform(order_id)
order = Order.find_by(id: order_id)
return if order.costs_captured? # Idempotency guard
# ...
end
end
If the Stripe API is having a bad minute, the job retries with exponential backoff. If it succeeds on attempt 3, the costs_captured? guard (checks gross_profit_cents.present?) prevents re-processing on attempt 4. We also handle partial success — if we get the Stripe fee but the Printify cost API is down, we write what we have instead of failing the whole job.
Gotcha 4: Missing Webhook Secret Should Be a 500, Not a Silent Pass
if endpoint_secret.present?
event = Stripe::Webhook.verify_and_construct(payload, sig_header, endpoint_secret)
else
Rails.logger.error("Webhook secret not configured")
head :internal_server_error
return
end
If the webhook secret is missing from your credentials, you have two options: reject the request (safe) or parse the event without verification (dangerous). We've seen tutorials that fall through to unverified parsing when the secret is nil. That's an open door — anyone can POST fake events to your webhook endpoint.
Return 500. Stripe will retry, your error monitoring will fire, and you'll fix the configuration. A missing secret is an infrastructure failure, not a "degrade gracefully" situation.
Gotcha 5: head :ok Position Matters More Than You Think
Stripe waits for your response before considering the webhook delivered. If your handler takes more than a few seconds — say, it's sending emails and calling a fulfillment API — Stripe times out and retries. Now you're processing the same event twice, concurrently.
The safe pattern: acknowledge immediately, process asynchronously. In Rails, that means enqueuing a job for the heavy work or at minimum making sure your slow operations (email, fulfillment) use deliver_later and background jobs.
Our webhook returns head :ok at the end of the method, after processing. We get away with this because our heavy operations (deliver_later, Printify fulfillment) are already async. But if you're doing synchronous work in your webhook handler, move the head :ok up and the processing into a job. Stripe's retry window is shorter than you'd expect.
The Pattern
Every gotcha follows the same principle: your webhook endpoint will be called more than once, possibly concurrently, and possibly while your own frontend is doing the same work. Design for it.
Three rules that prevent most webhook bugs:
- Database constraints over application logic.
find_or_initialize_byis a hint. A unique index is a guarantee. - Acknowledge fast, process async.
deliver_later, background jobs,perform_later. Keep your webhook response time under a second. - Every handler must be safe to call twice. If your handler isn't idempotent, the next Stripe retry will prove it.
Next time: Automating product creation with the Printify API — blueprint selection, variant management, and the joy of programmatic mockup generation.