Payments · Lesson 4
Async Confirmation: Trust the Webhook, Not the Redirect
When the user lands on /success, the money hasn't necessarily moved yet. Trusting the redirect is one of the most common payment bugs in shipped Rails apps.
The naive flow
# Controller for the /success page:
def show
@order = Order.find(params[:id])
@order.update!(paid_at: Time.current, status: "paid") # ⚠️
redirect_to thank_you_path
end
The user comes back from the payment provider with a payment_intent ID in the URL. We see it. We mark the order as paid. The user gets the thank-you page.
The problem: the URL parameter only proves the user reached this page, not that the payment cleared. The user might have made it back before the payment processor finished. The user might have clicked a link an attacker generated. The user might have an extension that messed with the URL. In any of those cases, you marked the order paid without confirmed money in your account.
The right flow
The provider has two channels for telling you what happened: the redirect (best-effort, client-controlled) and the webhook (signed, server-to-server, retried until you ack). Treat the webhook as the source of truth.
- The redirect lands the user on a "we're processing your payment" page. It does not change the order status.
- The webhook arrives shortly after with a signed
payment_intent.succeededevent. Your handler updates the order. Your handler enqueues the confirmation email. - The user's "we're processing" page polls (or uses Turbo Streams / Cable) until the order status flips to paid, then auto-advances to the thank-you page.
The flow in code
# Return-from-provider page:
def show
@order = Order.find(params[:id])
# Do NOT mark paid here. Just show a status view.
end
# Webhook handler (the authoritative path):
when "payment_intent.succeeded"
intent = event.data.object
order = Order.find_by!(payment_intent_id: intent.id)
Order.transaction do
order.update!(status: :paid, paid_at: Time.current)
OrderMailer.with(order: order).receipt.deliver_later
end
# Status view auto-refreshes via Turbo Stream or by polling:
<% if @order.paid? %>
<%= turbo_stream.replace "order_#{@order.id}_status",
partial: "thank_you", locals: { order: @order } %>
<% else %>
<div data-controller="order-status"
data-order-status-url-value="<%= status_order_path(@order) %>">
<p>We're confirming your payment. This usually takes a few seconds.</p>
</div>
<% end %> When the webhook is slow
Most webhooks arrive in under a second. Some take longer. A small fraction take minutes or hours (e.g., bank-funded payments, async payment methods like SEPA). The status page should make this visible: "your payment is processing, we'll email you when it clears." Don't make the user sit on a spinner indefinitely. Render a state that acknowledges the wait, and let the email or push notification close the loop.
The general principle
The client redirect is a UX signal. The webhook is the state machine. Two channels, two purposes. The bug shape — trusting the redirect — happens because they look similar from the application code's view (both deliver a payment_intent ID). They are not the same. One is "the user got here." The other is "the money moved." Senior code recognizes the difference.