osmoto.
Case StudiesBlogBook Consultation

Services

Stripe IntegrationSubscription BillingPayment Automation & AINext.js OptimizationAudit & Fix

Solutions

For FoundersFor SaaS CompaniesFor E-Commerce StoresFor Marketplaces

Resources

Implementation GuideWebhook Best PracticesPCI Compliance GuideStripe vs Alternatives
Case StudiesBlog
Book Consultation
osmoto.

Professional Stripe integration services

Services

  • Stripe Integration
  • Subscription Billing
  • E-Commerce Integration
  • Next.js Optimization
  • Audit & Fix

Solutions

  • For Founders
  • For SaaS
  • For E-Commerce
  • For Marketplaces
  • Integration as a Service

Resources

  • Implementation Guide
  • Webhook Guide
  • PCI Compliance
  • Stripe vs Alternatives

Company

  • About
  • Case Studies
  • Process
  • Pricing
  • Contact
© 2026 Osmoto · Professional Stripe Integration Services
Back to Blog
Stripe Integration16 min read

Stripe Payment Intent vs Charge API: When to Use Each

You're building a payment integration and staring at Stripe's documentation, wondering: should you use Payment Intents or the Charges API? This isn't just an ac...

Osmoto Team

Senior Software Engineer

February 25, 2026
Stripe Payment Intent vs Charge API: When to Use Each

Introduction

You're building a payment integration and staring at Stripe's documentation, wondering: should you use Payment Intents or the Charges API? This isn't just an academic question—choosing the wrong API can mean rebuilding your entire payment flow later, missing out on critical features like 3D Secure authentication, or worse, losing sales due to higher decline rates.

The Charges API was Stripe's original payment method, launched in 2010. It's simple, direct, and still works perfectly fine for basic card captures. Payment Intents, introduced in 2019, represents Stripe's modern approach to handling payments with built-in support for Strong Customer Authentication (SCA), 3D Secure 2, and multiple payment methods. The choice between them affects your authentication flows, error handling, webhook architecture, and ability to support emerging payment methods.

In this guide, we'll compare both APIs in depth, examine real-world scenarios where each excels, walk through the technical differences that matter, and provide a practical migration path if you're currently using Charges. By the end, you'll understand exactly which API fits your use case and how to implement it correctly.

Understanding the Fundamental Difference

The Charges API: Direct Payment Capture

The Charges API follows a straightforward model: you create a charge, Stripe attempts to capture funds, and you get a result. Here's the basic flow:

const stripe = require('stripe')(process.env.STRIPE_SECRET_KEY); // Traditional Charges API approach const charge = await stripe.charges.create({ amount: 2000, currency: 'usd', source: 'tok_visa', // or a saved card ID description: 'Product purchase', }); // charge.status is either 'succeeded' or 'failed'

This works well for simple scenarios, but it has limitations. If the payment requires authentication (like 3D Secure), you need to handle that separately using the Sources API or manually redirect customers. The Charges API doesn't orchestrate authentication flows—it's your responsibility to coordinate everything.

Payment Intents: State Machine for Modern Payments

Payment Intents introduce a state machine that tracks a payment through its entire lifecycle, from creation through authentication to capture:

// Modern Payment Intents approach const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', payment_method: 'pm_card_visa', confirm: true, return_url: 'https://example.com/order/complete', }); // paymentIntent.status can be: // - 'requires_payment_method' // - 'requires_confirmation' // - 'requires_action' (needs 3D Secure) // - 'processing' // - 'succeeded' // - 'canceled'

The key difference: Payment Intents handle the complexity of authentication for you. When a payment requires 3D Secure, the status becomes requires_action, and you get a next_action object with instructions for completing authentication. Stripe's client-side libraries handle the redirect, iframe, or biometric authentication automatically.

When to Use the Charges API

Despite being the older API, Charges still has legitimate use cases where its simplicity is actually an advantage.

Scenario 1: Terminal or Phone Payments

If you're processing card-present transactions through Stripe Terminal or taking payments over the phone (MOTO - Mail Order/Telephone Order), the Charges API often makes more sense:

// Card-present transaction via Terminal const charge = await stripe.charges.create({ amount: 5000, currency: 'usd', source: terminalReader.payment_method, description: 'In-store purchase', metadata: { location_id: 'store_123', terminal_id: terminalReader.id, }, });

Card-present transactions don't require online authentication flows, making the Charges API's direct approach perfectly suitable. Payment Intents add unnecessary complexity here without providing additional value.

Scenario 2: Legacy Systems with Existing Integrations

If you have a stable payment system built on the Charges API that's been running for years, migration might not be worth the effort—especially if you:

  • Only process cards from regions without SCA requirements (though this is shrinking)
  • Have extensively tested error handling and edge cases
  • Don't need to add new payment methods like wallets or bank debits
  • Operate in a low-volume environment where authentication rates are minimal

That said, Stripe will eventually deprecate the Charges API. While there's no announced timeline, building new systems on Charges means technical debt you'll need to address later.

Scenario 3: Simple One-Off Payments Without Saved Cards

For extremely simple use cases—think donation forms or one-time purchases where you never save payment methods—Charges can work:

// Simple donation with Stripe Checkout const session = await stripe.checkout.sessions.create({ payment_method_types: ['card'], line_items: [{ price_data: { currency: 'usd', product_data: { name: 'Donation' }, unit_amount: 5000, }, quantity: 1, }], mode: 'payment', success_url: 'https://example.com/success', cancel_url: 'https://example.com/cancel', });

However, even Stripe Checkout now uses Payment Intents under the hood. If you're building anything new, starting with Payment Intents future-proofs your integration.

When to Use Payment Intents (Almost Always)

Payment Intents should be your default choice for any new payment integration. Here's why, with specific scenarios.

Scenario 1: European or UK Customers (SCA Compliance)

Strong Customer Authentication is mandatory in Europe under PSD2. Payment Intents handle SCA automatically:

const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'eur', payment_method: paymentMethodId, confirm: true, return_url: 'https://example.com/complete', // Stripe determines if SCA is required based on: // - transaction amount // - customer location // - card issuer requirements }); if (paymentIntent.status === 'requires_action') { // Client-side: Stripe.js handles the 3D Secure challenge const { error } = await stripe.confirmCardPayment( paymentIntent.client_secret ); }

The Charges API requires you to manually implement 3D Secure using the Sources API, which is significantly more complex and error-prone. Payment Intents abstract this complexity while remaining compliant.

Scenario 2: Supporting Multiple Payment Methods

Modern customers expect options: cards, Apple Pay, Google Pay, ACH debits, SEPA debits, and regional methods like iDEAL or Klarna. Payment Intents support all of these through a unified interface:

const paymentIntent = await stripe.paymentIntents.create({ amount: 5000, currency: 'usd', payment_method_types: ['card', 'us_bank_account', 'klarna'], // Customer can choose their preferred method });

Each payment method has different authentication flows—ACH requires microdeposits or instant verification, Klarna redirects to their app—but Payment Intents handle all of these automatically. The Charges API only supports cards and a handful of legacy sources.

Scenario 3: Subscription Payments and Saved Cards

If you're building any kind of recurring billing, Payment Intents are non-negotiable. They integrate seamlessly with Stripe's subscription system and handle authentication for saved payment methods:

// Create a customer and save payment method const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', customer: customerId, payment_method: paymentMethodId, setup_future_usage: 'off_session', // Critical for subscriptions confirm: true, }); // Later, charge the saved payment method const futurePayment = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', customer: customerId, payment_method: savedPaymentMethodId, off_session: true, // Indicates this is a merchant-initiated transaction confirm: true, });

The setup_future_usage parameter tells the card issuer that you plan to charge this card again, which affects how they handle authentication. This is crucial for reducing false declines on subscription renewals. For a complete subscription implementation, check out our Stripe Subscriptions service.

Scenario 4: Reducing False Declines

Payment Intents provide better decline handling through their state machine. When a payment fails, you get detailed error information and can retry with different strategies:

try { const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', payment_method: paymentMethodId, confirm: true, }); } catch (error) { if (error.type === 'StripeCardError') { // Specific decline reason switch (error.decline_code) { case 'insufficient_funds': // Suggest a different payment method break; case 'card_velocity_exceeded': // Customer is trying too many cards too quickly break; case 'authentication_required': // Retry with 3D Secure break; } } }

Payment Intents also support Stripe Radar's machine learning for fraud detection and can request authentication selectively based on risk scores, balancing security with conversion rates.

Technical Implementation Differences

Webhook Handling

The webhook events differ significantly between APIs, and Payment Intents provide more granular tracking.

Charges API webhooks:

// You primarily listen for charge.succeeded and charge.failed app.post('/webhook', async (req, res) => { const event = req.body; switch (event.type) { case 'charge.succeeded': // Payment captured successfully await fulfillOrder(event.data.object.id); break; case 'charge.failed': // Payment failed await notifyCustomer(event.data.object.id); break; } });

Payment Intents webhooks:

// More states to track, but better visibility app.post('/webhook', async (req, res) => { const event = req.body; switch (event.type) { case 'payment_intent.succeeded': // Payment completed successfully await fulfillOrder(event.data.object.id); break; case 'payment_intent.payment_failed': // Payment attempt failed, but might retry await logFailedAttempt(event.data.object.id); break; case 'payment_intent.requires_action': // Authentication needed - usually handled client-side break; case 'payment_intent.processing': // Payment is being processed (common for bank debits) await updateOrderStatus(event.data.object.id, 'processing'); break; } });

The Payment Intents webhook flow gives you better visibility into payment states, especially for asynchronous payment methods like ACH or SEPA debits that take days to settle. For comprehensive webhook implementation, see our Webhook Implementation Guide.

Error Handling Complexity

Charges API errors are relatively simple—either the charge succeeds or it fails:

try { const charge = await stripe.charges.create({ amount: 2000, currency: 'usd', source: cardToken, }); // Success - charge.status === 'succeeded' } catch (error) { // Failure - handle the error console.error(error.message); }

Payment Intents require handling multiple states and potential race conditions:

const handlePaymentIntent = async (paymentIntentId) => { const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId); switch (paymentIntent.status) { case 'succeeded': return { success: true }; case 'requires_action': // Return client_secret for frontend to complete authentication return { requiresAction: true, clientSecret: paymentIntent.client_secret, }; case 'requires_payment_method': // Previous payment method failed, need a new one return { requiresNewPaymentMethod: true, error: paymentIntent.last_payment_error?.message, }; case 'processing': // Payment is being processed (async payment methods) return { processing: true }; case 'canceled': return { canceled: true }; default: throw new Error(`Unexpected status: ${paymentIntent.status}`); } };

This complexity is actually a feature—it gives you fine-grained control over the payment experience. You can show different UI states for authentication, processing, or errors.

Idempotency Considerations

Both APIs support idempotency keys, but Payment Intents make them more critical due to their stateful nature:

const idempotencyKey = `order_${orderId}_attempt_${attemptNumber}`; const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', payment_method: paymentMethodId, confirm: true, }, { idempotencyKey: idempotencyKey, });

Since Payment Intents can be in various states and you might retry authentication or confirmation, idempotency keys prevent duplicate charges when network issues cause retries. For more on building robust payment endpoints, see Building Idempotent API Endpoints for Payment Processing.

Migration Path from Charges to Payment Intents

If you're currently using the Charges API and need to migrate, here's a practical approach that minimizes risk.

Phase 1: Parallel Implementation

Start by implementing Payment Intents for new transactions while keeping Charges running for existing flows:

// Feature flag or environment variable const usePaymentIntents = process.env.ENABLE_PAYMENT_INTENTS === 'true'; const processPayment = async (amount, paymentMethodId) => { if (usePaymentIntents) { return await processWithPaymentIntents(amount, paymentMethodId); } else { return await processWithCharges(amount, paymentMethodId); } };

This allows you to test Payment Intents in production with real traffic before committing fully.

Phase 2: Update Webhook Handlers

Add Payment Intent webhook handlers alongside your existing Charge handlers:

app.post('/webhook', async (req, res) => { const event = req.body; // Handle both event types during migration if (event.type.startsWith('charge.')) { await handleChargeEvent(event); } else if (event.type.startsWith('payment_intent.')) { await handlePaymentIntentEvent(event); } res.json({ received: true }); });

Ensure your order fulfillment logic can handle both Charge IDs and Payment Intent IDs:

const fulfillOrder = async (orderId) => { const order = await getOrder(orderId); // Check which payment type was used if (order.chargeId) { const charge = await stripe.charges.retrieve(order.chargeId); if (charge.status === 'succeeded') { await shipOrder(orderId); } } else if (order.paymentIntentId) { const paymentIntent = await stripe.paymentIntents.retrieve( order.paymentIntentId ); if (paymentIntent.status === 'succeeded') { await shipOrder(orderId); } } };

Phase 3: Migrate Saved Cards

Converting saved card tokens to Payment Methods is straightforward but requires customer interaction for SCA compliance:

// Old: Charges API with card tokens const customer = await stripe.customers.retrieve(customerId); const cardId = customer.default_source; // e.g., card_xxx // New: Payment Methods const paymentMethod = await stripe.paymentMethods.attach( paymentMethodId, { customer: customerId } ); await stripe.customers.update(customerId, { invoice_settings: { default_payment_method: paymentMethod.id, }, });

For saved cards that need authentication, you'll need to trigger a Setup Intent:

const setupIntent = await stripe.setupIntents.create({ customer: customerId, payment_method: existingCardId, confirm: true, }); if (setupIntent.status === 'requires_action') { // Customer needs to authenticate on the frontend return { clientSecret: setupIntent.client_secret }; }

Phase 4: Update Subscription Billing

If you're using subscriptions, update them to use Payment Methods instead of sources:

// Update existing subscriptions const subscription = await stripe.subscriptions.retrieve(subscriptionId); await stripe.subscriptions.update(subscriptionId, { default_payment_method: paymentMethodId, });

Stripe's subscription engine automatically uses Payment Intents for renewals when a Payment Method is attached, so this transition happens naturally.

Phase 5: Deprecate Charges Gradually

Once Payment Intents are handling 100% of new transactions successfully:

  1. Stop creating new Charges
  2. Monitor for any lingering references in your codebase
  3. Keep Charge webhook handlers active for 90 days (to handle disputes or refunds)
  4. Archive the Charges implementation code

If you need assistance with this migration, our Stripe Integration service includes migration support from legacy APIs to modern implementations.

Common Pitfalls and Edge Cases

Pitfall 1: Not Handling Asynchronous Payment Methods

Payment Intents support payment methods that don't settle immediately. ACH debits, SEPA debits, and some regional methods can take 3-7 days to complete:

const paymentIntent = await stripe.paymentIntents.create({ amount: 5000, currency: 'usd', payment_method_types: ['us_bank_account'], confirm: true, }); // status will be 'processing', not 'succeeded' console.log(paymentIntent.status); // 'processing' // You must wait for webhook to confirm success

Solution: Never fulfill orders immediately for async payment methods. Wait for the payment_intent.succeeded webhook:

app.post('/webhook', async (req, res) => { const event = req.body; if (event.type === 'payment_intent.succeeded') { const paymentIntent = event.data.object; // Check if this was an async payment method if (paymentIntent.payment_method_types.includes('us_bank_account')) { // Safe to fulfill now - funds are confirmed await fulfillOrder(paymentIntent.metadata.order_id); } } });

Pitfall 2: Incorrect Off-Session Payment Handling

When charging saved payment methods without the customer present (like subscription renewals), you must set off_session: true:

// WRONG - will fail if authentication is required const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', customer: customerId, payment_method: savedPaymentMethodId, confirm: true, }); // CORRECT - handles off-session scenarios const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', customer: customerId, payment_method: savedPaymentMethodId, off_session: true, confirm: true, });

If authentication is required for an off-session payment, the Payment Intent will fail with status requires_payment_method, and you'll need to email the customer to update their card. For handling these scenarios in subscriptions, see Handling Failed Subscription Payments: A Complete Dunning Strategy.

Pitfall 3: Exposing Client Secrets Insecurely

Payment Intent client secrets should be treated like API keys—they allow completing the payment:

// WRONG - never expose in client-side JavaScript <script> const clientSecret = "<?php echo $paymentIntent->client_secret; ?>"; </script> // CORRECT - fetch from your secure backend const response = await fetch('/api/create-payment-intent', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ amount: 2000 }), }); const { clientSecret } = await response.json();

Client secrets should only be sent to the browser that initiated the payment, and they expire after 24 hours. For comprehensive security practices, see Securing Stripe API Keys in Production Applications.

Pitfall 4: Not Testing 3D Secure Flows

Many developers test with simple card numbers that don't trigger authentication, then face issues in production:

// Use Stripe's test cards that require authentication const testCards = { requiresAuth: '4000002500003155', // Always requires 3D Secure authFails: '4000008400001629', // Authentication fails authInsufficient: '4000008400001280', // Insufficient funds after auth }; // Test your flow with these cards const paymentIntent = await stripe.paymentIntents.create({ amount: 2000, currency: 'usd', payment_method_data: { type: 'card', card: { token: 'tok_visa_authRequired' }, }, confirm: true, });

Always test authentication flows in your staging environment before going live. Stripe provides test cards specifically for this purpose in their documentation.

Best Practices Summary

Here's a concise checklist for implementing Payment Intents correctly:

Architecture:

  • ✅ Use Payment Intents for all new payment integrations
  • ✅ Implement proper webhook handling for all payment states
  • ✅ Store Payment Intent IDs in your database, not just Charge IDs
  • ✅ Use idempotency keys for all payment creation requests
  • ✅ Implement retry logic with exponential backoff for API calls

Security:

  • ✅ Never expose client secrets in URLs or logs
  • ✅ Validate webhook signatures using Stripe's library
  • ✅ Use HTTPS for all payment-related endpoints
  • ✅ Implement rate limiting on payment endpoints
  • ✅ Store API keys in environment variables, never in code

User Experience:

  • ✅ Show clear loading states during authentication
  • ✅ Provide specific error messages based on decline codes
  • ✅ Support multiple payment methods to increase conversion
  • ✅ Implement proper mobile authentication flows
  • ✅ Test with various card types and authentication scenarios

Compliance:

  • ✅ Set setup_future_usage for any saved payment methods
  • ✅ Use off_session: true for merchant-initiated transactions
  • ✅ Implement SCA exemptions appropriately (low-value, recurring)
  • ✅ Handle asynchronous payment methods correctly
  • ✅ Maintain audit logs of all payment attempts

For a complete security review of your payment implementation, consider our Stripe Audit & Fix service, which includes PCI compliance verification and vulnerability assessment.

Conclusion

The choice between Stripe's Payment Intents and Charges API is clear for most modern applications: Payment Intents provide better authentication handling, broader payment method support, and future-proof compliance with regulations like SCA. The Charges API remains viable only for specific scenarios like card-present transactions or legacy systems that don't require migration urgency.

If you're building a new payment integration, start with Payment Intents. The initial complexity pays dividends through reduced decline rates, better customer experience, and compatibility with emerging payment methods. If you're migrating from Charges, take a phased approach—implement Payment Intents in parallel, update your webhooks, migrate saved cards, and deprecate Charges gradually.

The key technical differences—state machine architecture, webhook events, and authentication handling—require thoughtful implementation but provide fine-grained control over the payment experience. Pay special attention to edge cases like asynchronous payment methods, off-session payments, and proper 3D Secure testing to avoid production issues.

Need help implementing Payment Intents or migrating from Charges? Our Stripe Integration service provides expert implementation of modern payment flows, including checkout optimization, webhook architecture, and authentication handling. We've helped dozens of companies build robust payment systems that maximize conversion while maintaining security and compliance.

Related Articles

Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Stripe Integration
Why Stripe Integration Is More Expensive Than Developers Quote (Hidden Complexity Explained)
Your developer quotes you two weeks to "hook up Stripe" for your SaaS product. Six weeks later, you're still not live. Payments work in testing but fail randoml...
How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI
Stripe Integration
How to Test Stripe Webhooks Locally with ngrok and the Stripe CLI
Testing Stripe webhooks during development can be frustrating. You make a change to your webhook handler, deploy to a staging environment, trigger a test paymen...
How to Handle Stripe Webhook Retries Without Duplicate Orders
Stripe Integration
How to Handle Stripe Webhook Retries Without Duplicate Orders
Picture this: your e-commerce store processes a $500 order, but due to a temporary network glitch, your webhook endpoint times out. Stripe automatically retries...

Need Expert Implementation?

I provide professional Stripe integration and Next.js optimization services with fixed pricing and fast delivery.