Migrating Checkout from Charges to Payment Intents

A legacy charge API is a function call: send an amount and a token, get success or failure. An intent-based API is a state machine: create an object, confirm it, possibly authenticate, then read the result — which may arrive by webhook rather than in the response. The migration between them is not a library upgrade; it is a change in how your application thinks about a payment. This guide sits under Payment Element Integration and covers making that change without breaking live checkout.

The forcing function is usually authentication. A synchronous charge cannot express “the issuer wants the cardholder to prove who they are”, so a legacy flow either fails those payments or routes around them, and in markets with strong-authentication requirements that is a large and growing share of transactions.

The second driver is asynchronous rails. Once bank debits and redirect methods are in scope, a payment that resolves in seconds is no longer the general case, and the intent model already has the vocabulary for it.

Trade-offs

Approach Handles authentication Handles async rails Migration effort Risk
Keep the charge API No No None Rising decline rate
Wrap charges in a retry-on-auth hack Partly, badly No Low Fragile, poor conversion
Full intent model Yes Yes Medium Managed with a flag
Provider-hosted checkout page Yes Yes Low Less control over UX
Charge call versus intent state machine A charge returns success or failure immediately, while an intent moves through requires confirmation, requires action, processing, and finally succeeded or failed. Charge call → success or fail Intent created requires action processing succeeded or failed
The intermediate states are the entire point — they are where authentication and asynchronous settlement live.

Provider-hosted checkout is a legitimate destination if the UX constraints are acceptable, and it removes most of this work. The trade is control: hosted pages limit branding, field ordering, and the ability to combine payment with other checkout steps.

Step-by-Step Implementation

1. Model the payment as a state machine locally

ALTER TABLE payments
  ADD COLUMN intent_ref   TEXT UNIQUE,
  ADD COLUMN state        TEXT NOT NULL DEFAULT 'created',
     -- created | requires_action | processing | succeeded | failed | cancelled
  ADD COLUMN last_error   TEXT,
  ADD COLUMN state_at     TIMESTAMPTZ NOT NULL DEFAULT now();

Mirroring the states locally is what lets your application answer “where is this payment?” without calling the provider, and it is the record the webhook consumer updates.

2. Create server-side, confirm client-side

// Server: create the intent with the amount and the customer, never trusting the client.
const intent = await provider.createIntent({
  amountMinor: invoice.amountDue,
  currency: invoice.currency,
  customerRef: customer.providerCustomerId,
  idempotencyKey: `inv:${invoice.id}:attempt:${invoice.attemptCount}`,
  setupFutureUsage: "off_session",     // store the credential for renewals
});
await payments.create({ invoiceId: invoice.id, intentRef: intent.id, state: "created" });
return { clientSecret: intent.clientSecret };

The amount must come from the server. A client that can influence the amount is a checkout that will eventually be abused, and this is the most common security defect introduced during such a migration.

3. Handle requires-action as a first-class branch

Confirmation and authentication branch Confirming on the client either completes the payment or returns a requires-action state that triggers an authentication challenge before completing. Client confirms succeeded webhook confirms server-side requires_action challenge, then re-confirm failed show the reason
The middle branch is what the legacy API could not express, and it is where a growing share of European transactions now go.

Never treat the client’s report of success as authoritative. The client tells the UI what to render; the webhook tells the server what happened. Details of the challenge experience itself are covered in routing 3DS2 SCA challenge flows.

4. Move renewals off-session

def charge_renewal(provider, invoice, method_ref) -> dict:
    return provider.charge_off_session(
        amount_minor=invoice.amount_due, currency=invoice.currency,
        payment_method_ref=method_ref,
        idempotency_key=f"inv:{invoice.id}:attempt:{invoice.attempt_count}",
        # off_session tells the provider nobody is present to authenticate
    )

Off-session charges can still return a requires-action result, and the correct response is not to retry blindly but to email the customer a link to complete authentication. A renewal that fails for authentication reasons is recoverable; treating it as a decline and running the retry ladder is not.

5. Run both paths behind a flag

Keep the legacy path callable, route a growing share of checkouts to the new one, and compare authorisation rates between them. Remove the old path only when it has been unused for a full billing cycle, so renewals as well as new checkouts have exercised the replacement.

What Changes Outside the Payment Call

The intent model changes several things that are not obviously part of checkout, and missing them is what makes the migration take longer than estimated.

Error rendering. A legacy failure was one string; an intent failure has a decline code, a category, and sometimes a next action. The UI needs to distinguish “your card was declined” from “your bank needs you to confirm this payment”, because the second is recoverable in the same session and the first is not.

Retry semantics. With a charge API, a retry means calling again. With intents, a failed intent can sometimes be re-confirmed and sometimes must be replaced, depending on why it failed. Encode that rule once, near the seam, rather than at each call site — and keep the idempotency key stable across a genuine retry so a network timeout cannot double-charge.

Refunds. Refunds reference the resulting charge rather than the intent, and a partially authenticated intent may have no charge at all. Store both references so a refund can always find its target.

Reporting. A payment now has intermediate states, and dashboards that counted “charges created” will over-report because intents are created for attempts that never complete. Recount from the terminal states, and add a funnel view — created, confirmed, authenticated, succeeded — which is genuinely more useful than the single number it replaces.

Support tooling. Agents need to see the intent state and the last error to answer “why did my payment not go through?”. Without it, every authentication-related question escalates to engineering, and authentication questions are the most common category after the migration.

Parallel path rollout A routing flag sends a growing share of checkouts to the intent path while the legacy path remains available, with authorisation rates compared between them. Checkout request Legacy charge path shrinking share Intent path growing share Compare authorisation rate
Both paths hit the same idempotency keys and the same invoices, so a routing change is reversible at any point.

Sequencing the Rollout

The migration has a natural order, and each phase is worth completing before the next begins.

New checkouts first. They are the smallest population, the easiest to observe, and the ones where a failure is visible immediately because a customer is watching. Route a small share, compare authorisation rates and completion rates against the legacy path, and expand only when the intent path is at least as good.

Renewals second, and separately. Off-session charges exercise a different branch — no customer is present, authentication cannot be completed interactively, and the recovery is an email rather than a modal. Treat this as its own rollout with its own cohorts, because a renewal regression affects the whole base rather than only new signups.

Refunds and disputes third. These reference charges rather than intents, and a partial migration can leave refund tooling looking at the wrong object. Verify that refunds work for charges created by both paths before removing either.

Reporting last. Dashboards built on the legacy object counts will misreport during the transition, and rewriting them before the routing is stable means doing it twice. Add the funnel view early as a supplement, and replace the legacy metrics once traffic has settled.

Remove the legacy path only after a full billing cycle with no traffic. Renewals are monthly for most subscriptions and annual for some, and a path that looks unused after a week may still be the one an annual cohort will take next quarter — so check by cohort rather than by aggregate volume before deleting anything.

Verification & Testing

Use the provider’s test instruments that force each branch: immediate success, authentication required then success, authentication required then failure, and hard decline. Assert the local state machine reaches the right terminal state in each case, and that the customer-facing message differs appropriately between decline and authentication.

Test that a client reporting success does not by itself mark the payment succeeded. Simulate a client success with no webhook and assert the payment stays in a non-terminal state until the webhook arrives, then assert the reconciler resolves it if the webhook never does.

Test idempotency across the migration boundary: the same invoice and attempt number must produce one charge whether it goes through the legacy path or the new one. This is the assertion that protects against a double charge while both paths are live.

Gotchas & Production Pitfalls

  • Amount supplied by the client. The most serious defect this migration can introduce. Create intents server-side with server-side amounts.
  • Client success treated as authoritative. Produces subscriptions activated for payments that later failed.
  • Off-session authentication treated as a decline. Sends a recoverable payment into the dunning ladder instead of asking the customer to confirm.
  • Idempotency key regenerated per attempt object. Turns a network timeout into a double charge. Derive the key from invoice and attempt.
  • Legacy path removed too early. Renewals exercise code paths that new checkouts do not; wait a full cycle.

Frequently Asked Questions

Can the migration be done gradually? Yes, and it should be. Route new checkouts first, then renewals, comparing authorisation rates at each step, and keep the legacy path until it is genuinely unused.

Does this improve conversion? In markets with strong-authentication requirements, substantially — payments that previously failed outright can now complete. Elsewhere the gain is smaller and the benefit is mainly future capability.

What about stored cards created under the old API? They generally continue to work, but the credential may lack the authentication history that supports exemptions, so expect a slightly higher challenge rate on the first intent-based charge for each.

Should we adopt the provider’s hosted page instead? If the checkout UX constraints are acceptable, it is a legitimate and much cheaper destination. Evaluate it explicitly rather than defaulting to a custom integration out of habit.