A developer’s guide to understanding Stripe’s API transformation and avoiding common migration pitfalls
The payment processing landscape has evolved dramatically over the past decade, and Stripe has been at the forefront of this transformation. One of the most significant changes in Stripe’s ecosystem was the transition from the Charges API to the Payment Intents API. This shift wasn’t just a cosmetic update – it represented a fundamental reimagining of how online payments should work in an increasingly complex global marketplace.
The Old World: Charges API (2011-2019)
The Simple Days
When Stripe first launched, online payments were relatively straightforward. The Charges API reflected this simplicity:
# The old way - direct charge creation
charge = Stripe::Charge.create({
amount: 2000,
currency: 'usd',
source: 'tok_visa', # Token from Stripe.js
description: 'Example charge'
})
if charge.paid
# Payment succeeded, fulfill order
fulfill_order(charge.id)
else
# Payment failed, show error
handle_error(charge.failure_message)
end
This approach was beautifully simple: create a charge, check if it succeeded, done. The API returned a charge object with an ID like ch_1234567890, and that was your payment.
What Made It Work
The Charges API thrived in an era when:
- Card payments dominated – Most transactions were simple credit/debit cards
- 3D Secure was optional – Strong customer authentication wasn’t mandated
- Regulations were simpler – PCI DSS was the main compliance concern
- Payment methods were limited – Mostly cards, with PayPal as the main alternative
- Mobile payments were nascent – Most transactions happened on desktop browsers
The Cracks Begin to Show
As the payments ecosystem evolved, the limitations of the Charges API became apparent:
Authentication Challenges: When 3D Secure authentication was required, the simple charge-and-done model broke down. Developers had to handle redirects, callbacks, and asynchronous completion manually.
Mobile Payment Integration: Apple Pay and Google Pay required more complex flows that didn’t map well to direct charge creation.
Regulatory Compliance: European PSD2 regulations introduced Strong Customer Authentication (SCA) requirements that the Charges API couldn’t elegantly handle.
Webhook Reliability: With complex payment flows, relying on synchronous responses became insufficient. Webhooks were critical, but the Charges API didn’t provide a cohesive event model.
The Catalyst: PSD2 and Strong Customer Authentication
The European Union’s Revised Payment Services Directive (PSD2), which came into effect in 2019, was the final nail in the coffin for simple payment flows. PSD2 mandated Strong Customer Authentication (SCA) for most online transactions, requiring:
- Two-factor authentication for customers
- Dynamic linking between payment and authentication
- Exemption handling for low-risk transactions
The Charges API, with its synchronous create-and-complete model, simply couldn’t handle these requirements elegantly.
The New Era: Payment Intents API (2019-Present)
A Paradigm Shift
Stripe’s response was revolutionary: instead of treating payments as simple charge operations, they reconceptualized them as intents that could evolve through multiple states:
# The modern way - intent-based payments
payment_intent = Stripe::PaymentIntent.create({
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
confirmation_method: 'manual',
capture_method: 'automatic'
})
case payment_intent.status
when 'requires_confirmation'
# Confirm the payment intent
payment_intent.confirm
when 'requires_action'
# Handle 3D Secure or other authentication
handle_authentication(payment_intent.client_secret)
when 'succeeded'
# Payment completed, fulfill order
fulfill_order(payment_intent.id)
when 'requires_payment_method'
# Payment failed, request new payment method
handle_payment_failure
end
The Intent Lifecycle
Payment Intents introduced a state machine that could handle complex payment flows:
requires_payment_method → requires_confirmation → requires_action → succeeded
↓ ↓ ↓
canceled canceled requires_capture
↓
succeeded
This model elegantly handles scenarios that would break the Charges API:
3D Secure Authentication:
# Payment requires additional authentication
if payment_intent.status == 'requires_action'
# Frontend handles 3D Secure challenge
# Webhook confirms completion asynchronously
end
Delayed Capture:
# Authorize now, capture later
payment_intent = Stripe::PaymentIntent.create({
amount: 2000,
currency: 'usd',
payment_method: 'pm_card_visa',
capture_method: 'manual' # Authorize only
})
# Later, when ready to fulfill
payment_intent.capture({ amount_to_capture: 1500 })
Key Architectural Changes
1. Separation of Concerns
Payment Intents represent the intent to collect payment and track the payment lifecycle.
Charges become implementation details—the actual movement of money that happens within a Payment Intent.
# A successful Payment Intent contains charges
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
puts payment_intent.charges.data.first.id # => "ch_0987654321"
2. Enhanced Webhook Events
Payment Intents provide richer webhook events that track the entire payment lifecycle:
# webhook_endpoints.rb
case event.type
when 'payment_intent.succeeded'
handle_successful_payment(event.data.object)
when 'payment_intent.payment_failed'
handle_failed_payment(event.data.object)
when 'payment_intent.requires_action'
notify_customer_action_required(event.data.object)
end
3. Client-Side Integration
The Payment Intents API encouraged better client-side integration through Stripe Elements and mobile SDKs:
// Modern client-side payment confirmation
const {error} = await stripe.confirmCardPayment(clientSecret, {
payment_method: {
card: cardElement,
billing_details: {name: 'Jenny Rosen'}
}
});
if (error) {
// Handle error
} else {
// Payment succeeded, redirect to success page
}
Migration Challenges and Solutions
The ID Problem: A Real-World Example
One of the most common migration issues developers face is the ID confusion between Payment Intents and Charges. Here’s a real scenario:
# Legacy refund code expecting charge IDs
def process_refund(charge_id, amount)
Stripe::Refund.create({
charge: charge_id, # Expects ch_xxx
amount: amount
})
end
# But Payment Intents return pi_xxx IDs
payment_intent = create_payment_intent(...)
process_refund(payment_intent.id, 500) # ❌ Fails!
The Solution: Extract the actual charge ID from successful Payment Intents:
def get_charge_id_for_refund(payment_intent)
if payment_intent.status == 'succeeded'
payment_intent.charges.data.first.id # Returns ch_xxx
else
raise "Cannot refund unsuccessful payment"
end
end
# Correct usage
payment_intent = Stripe::PaymentIntent.retrieve('pi_1234567890')
charge_id = get_charge_id_for_refund(payment_intent)
process_refund(charge_id, 500) # ✅ Works!
Database Schema Evolution
Many applications need to update their database schemas to accommodate both old and new payment types:
# Migration to support both charge and payment intent IDs
class AddPaymentIntentSupport < ActiveRecord::Migration[6.0]
def change
add_column :payments, :stripe_payment_intent_id, :string
add_column :payments, :payment_type, :string, default: 'charge'
add_index :payments, :stripe_payment_intent_id
add_index :payments, :payment_type
end
end
# Updated model to handle both
class Payment < ApplicationRecord
def stripe_id
case payment_type
when 'payment_intent'
stripe_payment_intent_id
when 'charge'
stripe_charge_id
end
end
def refundable_charge_id
if payment_type == 'payment_intent'
# Fetch the actual charge ID from the payment intent
pi = Stripe::PaymentIntent.retrieve(stripe_payment_intent_id)
pi.charges.data.first.id
else
stripe_charge_id
end
end
end
Webhook Handler Updates
Webhook handling becomes more sophisticated with Payment Intents:
# Legacy charge webhook handling
def handle_charge_webhook(event)
charge = event.data.object
case event.type
when 'charge.succeeded'
mark_payment_successful(charge.id)
when 'charge.failed'
mark_payment_failed(charge.id)
end
end
# Modern payment intent webhook handling
def handle_payment_intent_webhook(event)
payment_intent = event.data.object
case event.type
when 'payment_intent.succeeded'
# Payment completed successfully
complete_order(payment_intent.id)
when 'payment_intent.payment_failed'
# All payment attempts have failed
cancel_order(payment_intent.id)
when 'payment_intent.requires_action'
# Customer needs to complete authentication
notify_action_required(payment_intent.id, payment_intent.client_secret)
when 'payment_intent.amount_capturable_updated'
# Partial capture scenarios
handle_partial_authorization(payment_intent.id)
end
end
Best Practices for Modern Stripe Integration
1. Embrace Asynchronous Patterns
With Payment Intents, assume payments are asynchronous:
class PaymentProcessor
def create_payment(amount, customer_id, payment_method_id)
payment_intent = Stripe::PaymentIntent.create({
amount: amount,
currency: 'usd',
customer: customer_id,
payment_method: payment_method_id,
confirmation_method: 'automatic',
return_url: success_url
})
# Don't assume immediate success
case payment_intent.status
when 'succeeded'
complete_payment_immediately(payment_intent)
when 'requires_action'
# Send client_secret to frontend for authentication
{ status: 'requires_action', client_secret: payment_intent.client_secret }
when 'requires_payment_method'
{ status: 'failed', error: 'Payment method declined' }
else
# Wait for webhook confirmation
{ status: 'processing', payment_intent_id: payment_intent.id }
end
end
end
2. Implement Robust Webhook Handling
Webhooks are critical for Payment Intents—implement them defensively:
class StripeWebhookController < ApplicationController
protect_from_forgery except: :handle
def handle
payload = request.body.read
sig_header = request.env['HTTP_STRIPE_SIGNATURE']
begin
event = Stripe::Webhook.construct_event(
payload, sig_header, ENV['STRIPE_WEBHOOK_SECRET']
)
rescue JSON::ParserError, Stripe::SignatureVerificationError
head :bad_request and return
end
# Handle idempotently
return head :ok if processed_event?(event.id)
case event.type
when 'payment_intent.succeeded'
PaymentSuccessJob.perform_later(event.data.object.id)
when 'payment_intent.payment_failed'
PaymentFailureJob.perform_later(event.data.object.id)
end
mark_event_processed(event.id)
head :ok
end
private
def processed_event?(event_id)
Rails.cache.exist?("stripe_event_#{event_id}")
end
def mark_event_processed(event_id)
Rails.cache.write("stripe_event_#{event_id}", true, expires_in: 24.hours)
end
end
3. Handle Multiple Payment Methods Gracefully
Payment Intents excel at handling diverse payment methods:
def create_flexible_payment(amount, payment_method_types = ['card'])
Stripe::PaymentIntent.create({
amount: amount,
currency: 'usd',
payment_method_types: payment_method_types,
metadata: {
order_id: @order.id,
customer_email: @customer.email
}
})
end
# Support multiple payment methods
payment_intent = create_flexible_payment(2000, ['card', 'klarna', 'afterpay_clearpay'])
4. Implement Proper Error Handling
Payment Intents provide detailed error information:
def handle_payment_error(payment_intent)
last_payment_error = payment_intent.last_payment_error
case last_payment_error&.code
when 'authentication_required'
# Redirect to 3D Secure
redirect_to_authentication(payment_intent.client_secret)
when 'card_declined'
decline_code = last_payment_error.decline_code
case decline_code
when 'insufficient_funds'
show_error("Insufficient funds on your card")
when 'expired_card'
show_error("Your card has expired")
else
show_error("Your card was declined")
end
when 'processing_error'
show_error("A processing error occurred. Please try again.")
else
show_error("An unexpected error occurred")
end
end
The Future: What’s Next?
1. Embedded Payments
Stripe continues to innovate with embedded payment solutions that make Payment Intents even more powerful:
# Embedded checkout with Payment Intents
payment_intent = Stripe::PaymentIntent.create({
amount: 2000,
currency: 'usd',
automatic_payment_methods: { enabled: true },
metadata: { integration_check: 'accept_a_payment' }
})
2. Real-Time Payments
As real-time payment networks like FedNow and Open Banking expand, Payment Intents provide the flexibility to support these new methods seamlessly.
3. Cross-Border Optimization
Payment Intents are evolving to better handle multi-currency and cross-border transactions with improved routing and local payment method support.
Key Takeaways for Developers
- Payment Intents are the future: If you’re building new payment functionality, start with Payment Intents, not Charges.
- Embrace asynchronous patterns: Don’t expect payments to complete immediately. Design your system around webhooks and state management.
- Handle the ID confusion: Remember that Payment Intents (
pi_) contain Charges (ch_). Refunds and some other operations still work on charge IDs. - Implement robust webhook handling: With complex payment flows, webhooks become critical infrastructure, not nice-to-have features.
- Test thoroughly: The increased complexity of Payment Intents requires more comprehensive testing, especially around authentication flows and edge cases.
- Monitor proactively: Use Stripe’s dashboard and logs extensively during development and deployment to understand payment flow behavior.
Conclusion
The evolution from Stripe’s Charges API to Payment Intents represents more than just a technical upgrade—it’s a fundamental shift toward a more flexible, regulation-compliant, and globally-aware payment processing model. While the migration requires thoughtful planning and careful implementation, the benefits in terms of supported payment methods, authentication handling, and regulatory compliance make it essential for any serious payment processing application.
The key is to approach the migration systematically: understand the differences, plan for the ID confusion, implement robust webhook handling, and test extensively. With these foundations in place, Payment Intents unlock capabilities that simply weren’t possible with the older Charges API.
As global payment regulations continue to evolve and new payment methods emerge, Payment Intents provide the architectural flexibility to adapt and grow. The initial complexity investment pays dividends in long-term maintainability and feature capability.
For developers still using the Charges API, the writing is on the wall: it’s time to embrace the future of payment processing with Payment Intents.
Have you encountered similar challenges migrating from Charges to Payment Intents? What patterns have worked best in your applications? Share your experiences in the comments below.