Payments · Lesson 1
Payment Intents vs Direct Charges
The old API said "charge $50 to this card, return success or failure." The modern API says "create an intent to charge $50, walk it through a state machine." Why the change, and what it means for your Rails app.
The problem with "charge now, return result"
A direct-charge API has one step. You call it, the result comes back. That works until something fails in the middle: a 3D Secure challenge, a bank that requires authentication, a network timeout, an intermediate state where you don't know whether the charge succeeded. With a single-call API, your code has to figure out "did this work? did it half-work? should I retry?" — and a retry might double-charge the user.
# Old style:
charge = Stripe::Charge.create(amount: 5000, source: token)
if charge.status == "succeeded"
order.update!(paid_at: Time.current)
else
# What happened? Timeout? 3DS? Bank decline?
# If we retry, we might charge twice.
end The intent: a multi-step state machine
A PaymentIntent is a server-side object that represents the entire lifecycle of a single payment. You create it once with a unique idempotency key. It moves through states: requires_payment_method, requires_confirmation, requires_action (e.g., 3DS), processing, succeeded, canceled. At every step you know exactly where the payment is.
# Step 1: server creates the intent
intent = Stripe::PaymentIntent.create(
amount: 5000,
currency: "usd",
metadata: { order_id: order.id }
)
order.update!(payment_intent_id: intent.id, status: "awaiting_payment")
# Step 2: client confirms with the payment method
# Step 3: bank may require action (3DS), client handles it
# Step 4: Stripe sends a webhook when status reaches succeeded
# Your job: react to the webhook, not to the client's redirect. What this buys you
- Resumability. A payment that needs 3DS doesn't fail; it pauses in
requires_action. The user authenticates, the intent resumes. Your code didn't have to model "half-done." - One source of truth. The intent ID is in your database. Every webhook references the same ID. Disputes, refunds, retries — all the same ID.
- No double charges from retries. Creating an intent with the same idempotency key returns the existing intent. The charge can only happen once per intent.
- Provider-agnostic shape. Stripe, Adyen, Braintree, and others have converged on this pattern. Modeling your domain around "Payment" with a state machine fits all of them.
The Rails-side shape
You usually keep a Payment record that mirrors the provider's intent. Its state column shadows the provider's lifecycle. Webhooks update the state. Your application reads the state. The provider is the source of truth; your database is a cache of it.
class Payment < ApplicationRecord
belongs_to :order
enum status: {
pending: 0,
requires_action: 1,
processing: 2,
succeeded: 3,
failed: 4,
canceled: 5,
}
# Update from webhook events. Never set "succeeded" from the
# client redirect. The webhook is authoritative.
end