Back to Payments

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