Every payment integration starts the same way. One provider, their SDK, their webhooks, their field names, straight into the application. It works, it ships, and nobody thinks about it again until the day you need to move - at which point you discover the integration was never the hard part.
Rewriting API calls is a week of work. That is not what makes migrations expensive. These are.
If you take repeat payments, your customers' cards are held by your provider and referenced by a token that only they can resolve. Those tokens are worthless to anyone else. Your subscription billing, your one-click checkout, your saved payment methods - all of it runs on references that belong to a company you are trying to leave.
Card data migration between providers does exist. Both sides are PCI-compliant, the data can move under a controlled transfer, and the major providers have processes for it. But it is a project with paperwork, scheduling and cooperation from a provider who has no commercial interest in helping you leave quickly. Weeks, not afternoons.
The alternative is asking every customer to re-enter their card, and you will not get them all back. For a subscription business that is not a migration cost, it is a churn event.
Providers do not agree on what to call things, when to send them, or how hard to try. Event names differ. Delivery guarantees differ - at-least-once for some, best-effort for others. Retry schedules, signature schemes and ordering guarantees all differ. Some send you the full object, some send an ID you must go and fetch.
If you handled those events inline - updating orders directly in the webhook controller - then that logic is shaped around one provider's vocabulary and timing assumptions. Swapping the provider means rewriting your order lifecycle, which is usually the part of the system nobody wants to touch.
This is the subtle one and the most expensive to unpick. Payment
states are not standard. authorised,
captured, settled, pending and
completed mean materially different things across
providers, and some providers collapse states that others separate.
If you stored their status strings in your payments
table, your schema is now a description of their system rather than
yours. Every report, every state machine and every conditional in
your codebase encodes assumptions from a provider you are leaving.
The integration is a week. The tokens, the webhook semantics and the vocabulary in your schema are the quarter.
There is a route out of the token problem, and it is worth knowing about before you pick a provider rather than after.
Visa and Mastercard both run their own tokenisation services - Visa Token Service and Mastercard Digital Enablement Service. A network token replaces the card number at the scheme level rather than the gateway level, and it carries real advantages: measurably better authorisation rates, and automatic lifecycle updates so a reissued or expired card keeps working without a failed payment and a dunning email.
The part that matters here is portability, and it hinges on one detail most merchants never look at: who is the token requestor. Tokens are provisioned against a Token Requestor ID. If your gateway holds it - which is the default - the tokens are still theirs and you are no better off. If you hold it, or you use a provider that supports merchant-owned tokens, the same tokens work across acquirers.
Same technology, opposite outcome. Whose name is on the Token Requestor ID decides whether tokenisation is a lock-in mechanism or an exit route.
Becoming a token requestor yourself means scheme enrolment and is not trivial. The practical middle path is to choose a provider that supports merchant-owned network tokens and to ask the question during procurement, when you still have leverage, rather than during a migration when you have none.
See network tokens and who owns your cards for how this works in practice.
The fix is unglamorous and cheap if you do it early: own your own payment model, and treat every provider as a translation layer into it.
Concretely, three rules.
Your states are yours. Define the payment lifecycle your business actually has, not the one your provider exposes. Store your state. Store theirs alongside it as a reference if you like, but never branch on it outside the adapter.
Webhooks normalise before they touch anything. The controller's only job is to verify the signature, translate the event into your own internal event, and enqueue it. Nothing downstream should know which provider it came from. Idempotency belongs here too, keyed on your identifier rather than theirs.
The adapter is the only provider-aware code. One interface - authorise, capture, refund, tokenise, void - with a concrete implementation per provider. If provider names appear anywhere outside that directory, the abstraction has leaked.
app/payments/
gateway.rb # the interface your app talks to
adapters/
stripe_adapter.rb
worldpay_adapter.rb
events/
normaliser.rb # provider event -> your event
payment.rb # your states, your vocabulary
That is a few days of work on a new project, and it is the difference between adding a second provider in a sprint and rebuilding checkout over a quarter.
Most systems we are called into are. It is recoverable, and the order matters:
None of this requires a rewrite, and each step is independently useful even if you never change provider. Which is the point: the abstraction earns its keep as soon as you need a second provider for redundancy, geography or cost - and that need arrives sooner than most teams expect.
We build payment abstraction layers and run gateway migrations, including the token transfer. If you are integrated directly and starting to feel it, we can tell you what it would actually take.