Payment Provider Migration & Portability
Every subscription business eventually considers changing payment provider — for pricing, for a rail the incumbent does not support, for local acquiring, or because a single provider is a single point of failure. The migration is genuinely difficult, but the difficulty is rarely where teams expect: moving card data is a solved, well-documented process, while untangling provider-specific assumptions from application code, and keeping two webhook streams consistent during the overlap, is where projects stall. This page sits under Webhook Processing & Backend State Management and covers the migration as a state-management problem.
The organising principle is that a migration is never a switch. It is a period — typically two to six months — during which both providers are live, some customers bill on each, and every part of your system must be able to answer “which provider owns this subscription?” without guessing. Systems that cannot answer that question have to migrate in a single risky cutover; systems that can, migrate gradually and roll back per cohort.
Prerequisites
The Seam Comes First
Do not begin a migration by integrating the new provider. Begin by making the existing integration replaceable, which is a refactor you can ship, test, and benefit from regardless of whether the migration proceeds. The seam is a narrow interface expressing what your billing logic actually needs — not a lowest-common-denominator wrapper over two SDKs.
// The seam: verbs your billing engine needs, in your own vocabulary.
export interface PaymentProvider {
readonly key: "stripe" | "adyen" | "braintree";
createCustomer(input: { accountId: string; email: string }): Promise<{ providerCustomerId: string }>;
chargeOffSession(input: {
providerCustomerId: string;
paymentMethodRef: string;
amountMinor: number;
currency: string;
idempotencyKey: string; // ours, not theirs — stable across retries
invoiceId: string;
}): Promise<ChargeResult>;
refund(input: { chargeRef: string; amountMinor: number; idempotencyKey: string }): Promise<RefundResult>;
parseWebhook(rawBody: Buffer, signature: string): Promise<NormalizedEvent>;
}
// Normalised outcome — provider decline codes map INTO this, never out of it.
export type ChargeResult =
| { status: "succeeded"; chargeRef: string; settledAt: string }
| { status: "processing"; chargeRef: string }
| { status: "requires_action"; clientSecret: string }
| { status: "failed"; category: DeclineCategory; providerCode: string; retryable: boolean };
The DeclineCategory normalisation is where the value concentrates. Providers do not agree on decline taxonomies, and billing logic that switches on raw provider codes has to be rewritten for each one. Mapping every provider’s codes into your own small enumeration — insufficient funds, expired instrument, do-not-honour, suspected fraud, authentication required, permanent block — means the retry timing logic is written once and stays correct on both.
Similarly, the idempotency key must be yours. If it is derived from an invoice and attempt number in your system, a retry after a provider timeout is safe on either provider and remains meaningful across the migration. Keys generated by the provider SDK cannot give you that guarantee.
Moving Stored Credentials
Card data cannot be exported by you, but it can be moved between PCI-compliant providers by the providers themselves. Both card networks support this and every major processor has a documented migration process: you sign the paperwork, the incumbent transmits the vault to the receiver over a secure channel, and the receiver returns a mapping from old token to new token. Your job is the mapping, not the card data.
Store the mapping additively rather than overwriting. Keeping both references on the payment-method row means a failed charge on the new provider can fall back to the old one during the overlap, and it makes the rollback path real rather than theoretical.
ALTER TABLE payment_methods
ADD COLUMN provider TEXT NOT NULL DEFAULT 'stripe',
ADD COLUMN provider_ref TEXT, -- token at the CURRENT provider
ADD COLUMN legacy_provider TEXT,
ADD COLUMN legacy_ref TEXT, -- token at the OUTGOING provider
ADD COLUMN migrated_at TIMESTAMPTZ;
CREATE UNIQUE INDEX ON payment_methods (provider, provider_ref);
Expect the mapping to be incomplete. Tokens for expired cards, cards from issuers the new acquirer does not support, and single-use tokens will not carry across — a 2–5% shortfall is normal. Those customers need a re-authentication flow, and the time to build it is before the export lands, not after. Bear in mind that network tokens and provider tokens behave differently here: network tokens generally survive the move with their updater services intact, while provider-specific tokens are reissued and lose any provider-side card-updater history, which usually shows up as a small bump in expired-card declines in the first month after cutover.
Cutting Over Without Downtime
The cutover is per subscription, driven by a routing decision evaluated at charge time. Nothing is migrated by rewriting a global config.
def provider_for(subscription) -> str:
"""Routing is a property of the subscription, evaluated at charge time."""
if subscription.provider_pinned: # explicit override, e.g. rollback
return subscription.provider
if subscription.id in MIGRATION_COHORT: # cohort membership, not a percentage
return "adyen"
return "stripe"
Cohorts beat percentages here. A random 5% sample scatters migrated subscriptions across every renewal date, so a problem discovered on day three has already affected customers you cannot easily enumerate. A cohort defined by renewal window — “subscriptions renewing between the 8th and 12th” — gives you a bounded, listable blast radius and a natural pause point before the next window.
Order the cohorts by risk, lowest first: monthly card subscribers on small amounts, then larger monthly, then annual renewals, and last anything with a mandate on a non-card rail, since mandates frequently cannot be transferred at all and may need re-collection from the customer.
Running Two Webhook Streams
During the overlap both providers send events, and your consumer must handle them without letting one provider’s view of a subscription overwrite the other’s. Three rules keep this safe.
First, namespace event identifiers by provider. A deduplication key of evt_1P9x collides across providers eventually; stripe:evt_1P9x does not. This is a one-line change that is extremely painful to make after the fact, because the existing key space has to be rewritten.
Second, ignore events from the provider that does not own the subscription. An old provider will keep emitting for a subscription you have already moved — cancellations, card-updater notifications, dispute events — and applying those to a subscription now billing elsewhere produces exactly the kind of state corruption that is hard to trace.
Third, make ownership transitions explicit and monotonic, so a late event from the old provider cannot resurrect the old ownership.
def handle(event, db):
key = f"{event.provider}:{event.id}"
if not db.claim_idempotency_key(key):
return "duplicate"
sub = db.get_subscription(event.subscription_ref, provider=event.provider)
if sub is None:
return "unknown"
if sub.provider != event.provider:
# The old provider is talking about a subscription we already moved.
db.record_ignored_event(key, reason="provider_mismatch")
return "ignored"
return apply(event, sub, db)
Disputes are the exception that proves the rule. A chargeback on a charge made at the old provider is handled by the old provider even months after the subscription moved, so dispute events must be routed by the charge’s provider, not the subscription’s. Getting that backwards means dispute evidence deadlines are missed silently — see automating chargeback dispute evidence submission for what those deadlines cost.
Edge Cases & Failure Modes
- Refunds against the old provider. A refund must go back through the provider that took the money. Route refunds by the charge’s provider reference, and never assume the subscription’s current provider can refund a historical charge.
- The dispute tail. Chargebacks can arrive four months after a charge. The old account must stay open, funded, and monitored until the last charge passes its dispute horizon, which usually means keeping the integration alive well beyond the last renewal.
- Payout reconciliation across two shapes. Each provider reports fees, reserves, and payout timing differently. Normalise both into your own ledger as described in reconciling payouts against your ledger, or month-end close becomes a manual merge.
- Trial and schedule state. Provider-side objects like subscription schedules, trial ends, and pending updates rarely map cleanly. Rebuild them from your own state rather than trying to translate the provider’s representation.
- Silent double billing. If the old subscription is not cancelled at the old provider when the new one is created, both will renew. Make cancellation of the source subscription part of the same transaction that pins the new provider, and reconcile nightly for subscriptions that appear active at both.
Sequencing the Programme
A migration has a natural order, and running the phases out of sequence is the most common reason one stalls for a year. Each phase should be shippable and valuable on its own, so that pausing between phases leaves the system in a better state than it started rather than half-migrated.
Phase one is the seam, shipped against the incumbent only. No new provider, no new behaviour, just the interface and the normalised decline categories. This phase pays for itself even if the migration is cancelled, because it removes provider objects from business logic and makes the decline taxonomy testable. Expect it to take longer than estimated: the surprises are always the places where provider objects leaked into templates, admin tools, and reporting queries rather than the core charge path.
Phase two is read-only integration with the new provider. Create customers, store tokens, receive and parse webhooks — but charge nothing. This surfaces API-shape mismatches, webhook signature handling, and the volume of fields your model cannot represent, at zero financial risk. The output is a filled-in contract test suite showing both providers producing identical normalised results.
Phase three is the vault migration. It has a long lead time driven by paperwork and by the providers’ own scheduling, so start the commercial process during phase one even though the technical work happens here. Import the mapping additively, reconcile the counts, and enumerate the shortfall so the re-authentication campaign can be prepared while the remaining phases proceed.
Phase four is shadow traffic. Authorise and void on the new provider for a sample of renewals, and compare authorisation rates. This is the phase that catches acquirer-level problems — a market where the new acquirer has materially worse issuer relationships, or a currency that routes through an unexpected entity — and it is the phase most often skipped, usually to the migration’s cost.
Phase five is cohort cutover, ordered by risk as described above, with a defined pause between cohorts and a rollback rehearsed before the first one moves.
Phase six is decommissioning, which cannot begin until the dispute horizon closes. Keep the old integration deployed and its credentials valid; a decommission that removes the ability to respond to a chargeback four months later trades a small cleanup task for an unrecoverable loss.
Keeping Both Sides Reconciled During the Overlap
The overlap is the period of maximum risk, and the control that manages it is a reconciliation that runs against both providers independently and compares each to your own state rather than to each other. Comparing the providers to one another is tempting and wrong: they hold different subsets of customers by design, so differences between them are expected and tell you nothing.
The three checks worth running nightly are ownership, existence, and amount. Ownership asks whether any subscription is active at both providers — the double-billing condition — and should always return zero rows. Existence asks whether every subscription your system believes is billing at a provider actually exists there, and vice versa, catching both failed creations and orphaned objects left behind by a partial cutover. Amount asks whether the next charge amount your system expects matches what the provider will charge, which catches quantity and price drift introduced by a mid-migration amendment applied to only one side.
-- Ownership check: any subscription active at more than one provider is an incident.
SELECT s.subscription_id,
count(DISTINCT pm.provider) AS provider_count,
array_agg(DISTINCT pm.provider) AS providers
FROM subscriptions s
JOIN provider_subscription_mirror pm USING (subscription_id)
WHERE pm.status = 'active'
GROUP BY s.subscription_id
HAVING count(DISTINCT pm.provider) > 1;
Alert on any non-empty result immediately rather than at the next business day, because a double-billed customer discovers the problem faster than a daily report does. The mirror table is worth maintaining for the duration of the overlap even though it is redundant with the providers’ own APIs: it makes the reconciliation a local query rather than thousands of API calls, and it survives a provider outage that would otherwise blind the check exactly when you most want it.
Retire these checks only after decommissioning, and keep the ownership one permanently if you end up running two acquirers for redundancy rather than migrating outright. It is cheap, and the condition it detects is one of the few billing bugs that customers escalate publicly.
Testing Strategy
The highest-value test suite is a contract test run against both providers’ sandboxes through the seam interface, asserting identical normalised outcomes for the same scenarios: a successful off-session charge, an insufficient-funds decline, an authentication-required response, a partial refund, and a duplicate request under the same idempotency key. If both providers produce the same ChargeResult shape for each scenario, the billing engine above the seam does not care which one is live — and that equivalence, verified continuously, is what makes the cutover boring.
Add a shadow-charge phase before any real traffic moves: for a sample of renewals, authorise on the new provider and immediately void, while the real charge still runs on the old one. That surfaces acquirer-level surprises — 3DS challenge rates, issuer decline patterns, currency support gaps — with zero customer impact. Compare authorisation rates between the two, since a new acquirer with a worse rate is a revenue regression that no functional test will catch.
Finally, rehearse the rollback. Pick a migrated cohort, pin it back to the old provider, and run a renewal cycle. A rollback path that has never been executed is a hope, and the moment you need it will be the moment you have least appetite for discovering it does not work.
Frequently Asked Questions
How long should both providers stay live? Until the last charge on the old provider passes its dispute window, which for cards is at least 120 days after the final renewal. Plan for two quarters of overlap, and budget for the incumbent’s minimum fees during that time.
Can I migrate without moving card data? Yes, by asking customers to re-enter their details, and you will lose a meaningful share of them. Re-entry campaigns typically recover a majority but not all, and the customers who lapse are disproportionately the ones with low engagement — which is to say, the ones a renewal would have retained silently. Use the vault migration wherever the providers support it.
Should I migrate to two providers for redundancy instead? Running two acquirers with intelligent routing is a real strategy, but it doubles the reconciliation surface permanently rather than temporarily. It earns its cost at scale, or where a single acquirer cannot cover your markets; below that, the complexity outweighs the resilience.
Do I need to re-run 3DS authentication after migrating? Frequently yes for European customers, because the exemption and mandate history attached to a stored credential is provider-scoped. Expect an elevated challenge rate on the first charge at the new provider and communicate it to support in advance — see routing 3DS2 SCA challenge flows.
What is the single most common cause of a stalled migration? Application code that reads provider objects directly. Every place your business logic touches a provider’s subscription, invoice, or price object is a place the migration has to stop and rewrite. Building the seam first turns a rewrite into a configuration change.
Related
- Webhook Processing & Backend State Management
- Database Sync & Consistency Patterns
- Idempotency & Event Deduplication
- Secure Card Vaulting & Tokenization
- Alternative Payment Methods & Local Rails
- Reconciliation & Double-Entry Ledger
- Building a Provider-Neutral Payment Interface
- Migrating Stored Cards Between Payment Processors
- Running Dual-Provider Writes During a Cutover