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 |
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
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.
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.