Every commerce platform eventually meets the same email: "we've negotiated better rates with a different payment gateway — how long to switch?" In most systems the honest answer is a project: the provider's SDK is threaded through the checkout page, the credentials live in the platform operator's vault, and the switch is a change request that travels through someone else's release calendar. We wanted the honest answer to be: change a configuration record, paste your keys into your own secret store, and the next shopper pays through the new provider.
The provider is data; driving it is code
The design rule that makes this work is a hard boundary. Which gateway a storefront uses is data: a merchant account record names the provider, the environment — test or live — the currency, and two references into the tenant's own encrypted secret store. A storefront channel points at a merchant account, or falls back to the tenant's default. How to drive a gateway is never the tenant's code — it is ours, shipped platform-wide behind a contract that never changes shape. Razorpay and Stripe ship as adapters today; choosing one is a row in your configuration.
We considered the obvious flexible alternative — letting tenants supply their own payment integration scripts — and rejected it deliberately. The checkout page is where shoppers type card credentials. It is the most skimming-sensitive surface in the product, and the difference between "no tenant-authored code runs here" and "tenant-authored code runs here" is the difference between a compact PCI self-assessment and a much longer one. Extensibility is a virtue; on this one page, it is a liability.
The long tail, without a release train
An adapter per provider is honest work, but it puts every provider on our release calendar — which is the same delay we were trying to remove, moved one floor up. So the common case got its own path. A provider that redirects the shopper to a hosted payment page and signs its webhooks with HMAC-SHA256 over the raw body — the shape most regional gateways share — is described by a single JSON descriptor rather than code: the endpoints to call for payment, status and refund, per-environment URLs so test and live are separate by construction, how to authenticate, where in the response to find the intent and the redirect URL, and an explicit map from the provider's event names to ours. One engine runs any descriptor.
Two boundaries keep that from becoming a liability. It is platform-authored configuration, not tenant-authored — the rejection of tenant-supplied payment code stands unchanged; a descriptor names URLs the server calls, and the validator refuses plaintext HTTP, refuses a secret anywhere but the auth block, and refuses a name that would shadow a built-in provider. And the vocabulary is declared, not guessed: an event the map does not mention is reported as unknown rather than pattern-matched, which is precisely how a name shaped like "refund failed" gets misread as a refund that succeeded. An invalid descriptor is skipped and logged — that provider becomes unsupported, rather than the host crash-looping on someone's typo.
What stays code is as deliberate as what does not. Providers needing an on-page SDK checkout, or an exotic signature scheme — asymmetric signatures, canonicalised JSON, timestamp tolerances — remain adapters, because a descriptor language expressive enough to cover them would be an interpreter running on the money path. That is a line worth refusing to cross.
One contract, many sheets
On the client, every provider is wrapped in the same driver contract: given an intent, open the provider's payment sheet, and report exactly one outcome — paid, failed, dismissed, or unavailable. The checkout never names a provider; it asks the API which gateway the channel uses and picks the driver from a registry. The intent the server returns carries only what the sheet needs: the intent id, a client token, and the public half of the key pair. The secret half is resolved server-side, used to create the intent and verify callbacks, and does not travel to the browser. Descriptor-driven providers need no client work at all: the intent carries a redirect URL, and the storefront's generic redirect path takes the shopper to the hosted page — which is why onboarding one of those touches no front-end release either.
The subtlest part of the contract is what a "successful" sheet means: nothing, commercially. A payment sheet reporting success only tells us the human is done; it is a claim, not a conclusion. The conclusion is the provider's signed webhook, verified over the exact bytes it sent, and that is the event that places the order — the client merely polls the checkout session until the order exists. This ordering follows from the same reasoning as freezing the deal before the money moves: the browser is the least trustworthy witness in the transaction, so nothing commercial may depend on what it says happened.
The same discipline applies to the unhappy paths. A dismissed sheet is not a failed payment — a UPI collect request can be approved from the payer's app after the shopper closed the window — so dismissal never abandons the checkout. And a lost webhook is a delay, not a lost order: a recovery job sweeps pending payment attempts every few minutes and asks the provider directly, so the answer of record is always the gateway's, arriving by push or by pull.
Your keys, in your vault
Merchant credentials are the tenant's property, so they live in the tenant's encrypted secret store — the same one the integration hub uses — and the merchant account record holds only references. The platform resolves tenant secrets first and falls back to host configuration only where a tenant has stored nothing; the fallback never runs the other way, and a secret-store outage surfaces as a named error rather than a silent switch to someone else's account. Environments are bound, not advisory: a test-mode merchant account cannot be attached to a live channel, so the classic staging-keys-in-production incident is refused at configuration time rather than discovered in settlement.
Don't offer what the freeze would refuse
One more small mechanism earns its keep daily. Before a shopper ever reaches the payment step, the storefront asks a deliberately minimal public endpoint one question: does this channel have a gateway, and in which currency? The answer names no accounts and resolves no secrets — but it lets the payment screen drop UPI, cards and net banking when no merchant account exists, instead of offering a tender the freeze would refuse thirty seconds later. Pay-on-credit for account customers keeps working either way, because it never needed a gateway. The rule generalises: a checkout should never present a button whose failure is already knowable.
What switching actually looks like
So the answer to the email: create the new merchant account record, paste the keys into your secret store, paste our webhook URL into the provider's dashboard, verify it in test mode, and point the channel at the new account. The shopper-facing change is that the payment sheet looks like the new provider. The order book does not notice. That is what it means for the gateway to be data — and it is the difference between a rate negotiation you can act on and one you schedule. The operational detail — accounts, secrets, webhooks, recovery — is in the payments documentation.
