Payments PendingStripe PaymentIntent vs Charge API Migration

Stripe PaymentIntent vs Charge API Migration

A guide to safely migrating from Stripe's deprecated Charges API to PaymentIntents.

· 8 min read

Migrating from Stripe’s Charges API to PaymentIntents changes both the payment lifecycle and the endpoint. The safe path is incremental, with read-side compatibility before new writes, webhook-driven fulfillment, and tests that exercise authentication failures. Rystic’s stateful simulator provides a foundation for testing those transitions locally and in CI, including the ID mismatches that can otherwise survive the migration unnoticed.

Why Stripe deprecated the Charges API and what forced the change

The EU’s PSD2, effective January 2018, introduced regulatory demands that the synchronous Charges API could not elegantly meet. Its Strong Customer Authentication rules took effect in September 2019 and called for two-factor authentication, dynamic linking, and low-risk exemption handling. The Charges API has no support for SCA, card authentication that a bank requests, or businesses in India. Stripe has deprecated it for creating card payments and plans to remove support, while describing the create and capture methods as deprecated and scheduled for deletion. Existing integrations can still use it, but new integrations cannot use it to create payments, so access gives them time to migrate before support ends.

The architectural difference between a Charge and a PaymentIntent

Diagram: The PaymentIntent Lifecycle: States and Branches. Visualizes: Visualize the PaymentIntent state machine as described in the article.

A Charge represented a synchronous create-and-complete operation, whereas a PaymentIntent is a durable object that tracks a payment across multiple interactions. Its lifecycle includes requires_payment_method, requires_confirmation, requires_action, processing, and succeeded, with branches to canceled and requires_capture. These states make authentication and delayed completion part of the payment model. The integration must therefore understand where a payment is in its lifecycle.

That change does not remove Charges. A successful PaymentIntent contains a Charge representing the actual movement of money, and payment_intent.latest_charge returns its ch_ ID. With automatic confirmation, charge creation happens asynchronously based on client-side customer action, so the API response no longer establishes the final payment result. Testing must follow the object through its transitions and branches. Rystic provides verified Stripe simulators continuously synced against live API behavior, allowing developers to exercise that lifecycle locally without live credentials.

The ID mismatch problem and database schema changes that must precede code changes

The first migration hazard is an identifier that looks valid but refers to the wrong object. PaymentIntents carry pi_ IDs, while Charges carry ch_ IDs. Legacy refund code that expects a Charge ID can silently fail when given a PaymentIntent ID. Stripe’s migration guide therefore prescribes updating code that reads Charge properties before changing the write path. This establishes a consistent interface for old Charges-API payments and new PaymentIntent payments during the transition.

The bridging fields are payment_method_details and billing_details, both available on all API versions. Replace charge.source with charge.payment_method_details, and replace charge.source.id with charge.payment_method. The former object-type lookup, charge.source.object, becomes charge.payment_method_details.type. For billing information, charge.source.address_zip maps to charge.billing_details.address.postal_code, while charge.source.name maps to charge.billing_details.name. These mappings separate payment method information from the customer’s billing details.

Card-specific reads need equally explicit changes. Replace charge.source.last4 with charge.payment_method_details.card.last4, and replace charge.source.fingerprint with charge.payment_method_details.card.fingerprint. The card security code check result moves from charge.source.cvc_check to charge.payment_method_details.card.checks.cvc_check. Values also change format, with MasterCard becoming mastercard. For Google Pay, the old charge.source.tokenization_method value android_pay becomes google_pay in card.wallet.type within charge.payment_method_details.

Before introducing new writes, evolve the database to store stripe_payment_intent_id and payment_type columns. Those fields support the mixed period in which Charge-created records and PaymentIntent-created records coexist. Refunds and other Charge-dependent operations still need the correct Charge lookup. Against a stateful simulator such as Rystic’s, realistic pre-seeded PaymentIntent and Charge objects let CI exercise those lookups across the schema migration without waiting for live test transactions.

Migrating one-time payment flows step by step

Diagram: Four Steps to Migrate a One-Time Payment Flow. Visualizes: Show the four ordered migration steps as a vertical or horizontal stepped sequence.

The one-time payment migration has four ordered steps, beginning with server-side intent registration. Before this change, the Charges flow had no server-side setup before it collected payment details. Replace that absence with POST /v1/payment_intents, supplying amount and currency. The response provides the client_secret that the frontend needs. This creates the payment lifecycle object before the customer confirms the payment.

The second step changes client-side collection. Previously, stripe.createToken(cardElement) produced a token that the application sent to its server. Replace it with stripe.confirmCardPayment, passing clientSecret and a payment_method whose card is cardElement. The client now submits payment details directly to Stripe, and confirmation initiates charge creation. The frontend is no longer preparing a token for a later server-side charge request.

Previously, the server called POST /v1/charges with the tokenized source to create the payment. After the client confirmation change, that request has no place in the new flow and must be removed. This is a deletion rather than a replacement endpoint at the same point in the old sequence.

The fourth step relocates fulfillment. Previously, the server could fulfill an order after a successful Charge response. Replace that control flow with a webhook handler subscribed to payment_intent.succeeded. Stripe’s migration guide explicitly directs integrations to implement webhook support and monitor that event for fulfillment. Fulfillment must wait beyond both the first API response and the point when the client-side promise resolves.

Migrating flows that save cards to Customer objects

Saved-card flows add a future-use decision to PaymentIntent creation. Set setup_future_usage and provide a Customer ID so that Stripe automatically attaches the resulting PaymentMethod to that Customer after confirmation. The parameter describes how the saved payment method is intended to be used. Choose on_session when future payments will primarily happen while the customer is active in the application. Choose off_session when most billing will happen without the customer present, and use off_session when both uses are possible.

That choice matters because the current on-session interaction gives issuers an opportunity to perform SCA proactively. Completing authentication upfront can reduce friction when the business later charges the saved method off-session. Issuers can still decline a payment or require further authentication. Saving a card therefore prepares a later payment flow without resolving all of its possible outcomes.

Off-session recurring payments and the coming break from PaymentIntents

The Off-Session Payments API, now in private preview, replaces PaymentIntents created with off_session=true for recurring and unscheduled transactions that the business starts. It requires the Stripe-Version header value 2026-04-22.preview. This path applies to direct Payments integrations outside Stripe Billing subscriptions and invoices. Because it is still in preview, teams should plan for the move while their current PaymentIntent integrations remain in use.

The main change is asynchronous authorization. Instead of calling POST /v1/payment_intents with off_session=true, the integration calls POST /v2/payments/off_session_payments. The v2 response returns immediately with a pending status, and the result arrives through a webhook. The amount format also changes from separate amount and currency parameters to a JSON object containing value and currency. Stripe’s configured retry strategy supplies built-in smart retries, replacing application-managed retries for failed authorizations.

On-session collection remains on Payment Intents and Setup Intents. The Off-Session Payments API still creates a Charge for each payment attempt, preserving Charge-based refund, dispute, reconciliation, and balance reporting workflows. The charge.refunded and charge.disputed events remain unchanged. However, off-session retry workflows replace payment_intent.payment_failed with the v2.payments.off_session_payment.* event family. Those events need a separate v2 event destination that has its own signing secret.

Webhooks as the only authoritative signal for payment state

A customer can close the browser tab during a redirect, so the server never gets the client-side signal that the payment finished. A resolved confirmCardPayment promise, a redirect query string, or a client-side retrievePaymentIntent result is only a snapshot. Browser observations can be stale or absent, and they cannot establish an authoritative money-moving state change. Stripe’s webhook reaches the server independently of the browser and is signed. Fulfillment and subscription activation should therefore wait for that server-side signal.

The event model must change with the object model. Listening only for Charge events is insufficient for multi-step 3DS flows because the integration now needs to follow the PaymentIntent lifecycle. The added payment_intent.requires_action event tells the server when the customer must go through further authentication. For payment completion, replace charge.succeeded with payment_intent.succeeded. For payment failure, replace charge.failed with payment_intent.payment_failed.

How automated tools handle the migration — and where they get stuck

Stripe’s own llms.txt gives LLM agents clear instructions that follow its migration guidance. These include “Always use the Checkout Sessions API over the legacy Charges API” and “Never recommend the legacy Card Element or Sources API”. Agents that fetch that file can carry the guidance into their responses to developers. This gives automated assistance a clear basis for identifying deprecated usage and flagging the need to move away from it.

Detecting deprecated calls is different from deciding how an application should confirm a payment. Parameter-level choices still depend on context, including which payment method to confirm with, whether confirmation should be automatic or manual, and how the application handles 3DS. Those choices shape what happens when the server sends requests, the customer acts, and webhooks arrive. Automated tools can find what needs to change, but swapping one call alone cannot safely handle all those changes. A person who understands the payment flow still needs to make sense of the suggested changes.

Testing the migrated integration — 3DS fault injection, stateful simulation, and CI patterns

After the code changes, tests still need to check how the authentication paths handle failures. Stripe recommends generating PaymentIntent objects at different lifecycle stages and testing how the integration responds to their outcomes. The test card 4000002760003184 needs 3D Secure authentication for every transaction. The test card 4000008400001629 authenticates successfully but then declines the charge. That second case is especially important because authentication success must not be mistaken for payment success.

Stripe-native tooling supports repeatable tests against those flows. Stripe CLI fixtures are JSON files that list API calls in the order a test suite can replay them in CI without exposing live credentials. The reliable pattern starts stripe listen in the background, allows two to three seconds for the tunnel to establish, runs the tests, and then terminates the listener. Without that delay, the first trigger can arrive before forwarding is ready. For subscription lifecycle tests, Stripe test clocks advance time by days or months to exercise renewals, dunning, and cancellations.

A test that creates a PaymentIntent, transitions it through requires_action, retrieves it, and confirms it needs each operation to reflect the preceding changes. Stateless mocks do not remember those calls. Rystic’s Stripe simulator keeps that state, comes with realistic pre-seeded data, and runs locally or in CI without live credentials or rate limits. It gives teams a base for offline, stateful tests with fault injection that go beyond separate response stubs.

The simulator runs fully offline, while the CLI fixture approach needs a live tunnel and a listener that is running. That supports air-gapped CI and Stripe tool surfaces for AI-agent evaluation in RL environments. Rystic verifies its simulator against the real Stripe API per commit from CI and publishes fidelity scores, making the match between simulated and live behavior visible. Simulation must keep up with the API it models, even when keeping the expected responses unchanged would be easier.

In benchmarks that use live Stripe test keys, agents sometimes take expected validation errors for invalid inputs as a sign that the integration worked. Stateful simulation with fault injection makes that mistake reproducible and catchable in CI. The same discipline protects the payment migration itself, where success means the correct object reached the correct state and fulfillment followed the authoritative event. Replacing the API call is only complete when those behaviors survive the change.

Sources

  1. Charges versus Payment Intents APIs
  2. Migrate to the Payment Intents API
  3. Migrate from Payment Intents to the Off-Session Payments API
  4. Card payments on the Charges API
  5. Charges
  6. The PaymentIntent object

More in Stripe Integration