Back to Payments

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.succeeded event. 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.