One payment interface, many gateways and a separate PCI vault
How to support several payment gateways behind one interface while tokenised card data stays in a separate PCI vault, and what that does to your PCI DSS scope.
To use several payment gateways without spreading card data across your platform, put the card data in one place and let everything else work with tokens. Cards are captured in a form served by a separate vault, so card numbers never reach your main application. The vault stores them and hands your platform a token. Your platform talks to one payment interface, with an adapter per gateway, and the vault supplies the card details only at the moment a gateway needs them. The vault carries the heavy PCI DSS obligations; the rest of the platform, if it is properly isolated, can carry far fewer.
This article explains how I design that: the vault boundary, the gateway interface, routing between gateways, webhooks and idempotency, and what it does and doesn’t do for your PCI DSS scope. The code is illustrative TypeScript written for this page. It comes from shaping a payments stack for a holiday-rental platform in Australia and New Zealand, which put several Australian gateways behind one interface with a separate vault.
Why gateway tokens aren’t enough on their own
Every modern gateway offers tokenisation. You send it a card once, through its hosted form, and it gives you a token to charge later. With one gateway, that is often all you need, and it is the simplest way to keep card data off your servers.
The trouble starts with the second gateway. A token issued by one gateway can only be charged through that gateway, usually only under the merchant account that created it. So if you want to:
- let each of your customers choose their own gateway or acquirer,
- move customers from one gateway to another for price or features,
- fail over to a second gateway when the first is down, or
- route particular card types or currencies to a particular gateway,
then the stored cards have to be usable at more than one gateway. Either you keep the card details yourself, somewhere, or every stored card is tied to the gateway it was first saved with. Card schemes also issue network tokens, which can help, but support varies by gateway and acquirer, and you still need somewhere to hold them.
A separate vault is the “somewhere”. It’s the one component that holds card data, so it’s the one component that has to be built and run to the full standard.
The vault as a scope boundary
PCI DSS applies to the cardholder data environment: the systems that store, process or transmit card data, plus anything connected to them or able to affect their security. The aim of a vault design is to make that environment small and keep your main platform outside it.
The shape I use:
Browser Vault (PCI scope) Gateways
─────── ───────────────── ────────
card form (vault iframe) ──► store card, issue token
│
│ token
▼
Main platform (tokens only)
───────────────────────────
gateway adapter ── request ─► add card details ──────────► Gateway A
Gateway B
◄── result ── strip card data ◄── result ─ Gateway C
A few rules make the boundary real rather than drawn on a diagram:
- The card form belongs to the vault. The customer types card details into a form served from the vault’s own domain, embedded as an iframe or a redirect. The main platform’s page hosts the frame but never sees what’s typed into it.
- The platform only ever holds tokens. The vault token is a random identifier with no mathematical relationship to the card number. It is useless without the vault.
- The vault is its own deployment. A separate cloud account, its own network, its own access controls, its own deployment pipeline, and very few people who can touch it. On AWS that means a dedicated account, not just a separate stack in the main one.
- The vault is small and boring. It captures, stores, forwards and deletes card data. It doesn’t know about bookings, invoices or customers. Every feature added to it is a feature you have to secure and assess to the full standard.
- No security codes are stored. The card security code (CVV/CVC) is sensitive authentication data. PCI DSS doesn’t allow it to be kept after authorisation, even encrypted, even in the vault. Stored-card payments rely on the card schemes’ stored-credential rules instead.
Building your own vault is a real commitment. Holding card data on behalf of your customers usually makes you a service provider under PCI DSS, which means validating as one, and above certain transaction volumes the card brands require an assessment by a Qualified Security Assessor. If that is more than you want to take on, third-party vault services exist that do exactly this job, and the rest of this design works the same way with one of them in the vault’s place.
One payment interface, an adapter per gateway
Your product shouldn’t know which gateway it is using. It talks to one interface, expressed in your own terms, and each gateway gets an adapter that translates:
type Money = { cents: bigint; currency: 'AUD' | 'NZD' };
type ChargeRequest = {
paymentId: string; // ours; also the idempotency key
merchantId: string; // which customer's merchant account
cardToken: string; // vault token, never a card number
amount: Money;
initiatedBy: 'customer' | 'merchant';
};
type ChargeResult =
| { status: 'approved'; gatewayRef: string }
| { status: 'declined'; reason: string; retryable: boolean }
| { status: 'unknown'; gatewayRef?: string }; // timeout: we don't know yet
interface PaymentGateway {
readonly name: string;
charge(req: ChargeRequest): Promise<ChargeResult>;
refund(gatewayRef: string, amount: Money, key: string): Promise<RefundResult>;
lookup(paymentId: string): Promise<ChargeResult | null>;
parseWebhook(raw: RawWebhook): Promise<GatewayEvent>;
}
Two details in that interface matter more than they look.
The result has an unknown status. A payment request that times out may or may not have been charged. Pretending it failed is how customers get charged twice; pretending it succeeded is how you ship a booking nobody paid for. unknown forces the calling code to deal with it, and lookup gives the adapter a way to find out.
The adapter never sees a card number. It builds the gateway’s request with placeholders where the card details go, and sends it through the vault’s forwarding endpoint. The vault swaps the placeholders for the real values, calls the gateway, and returns the response with any card data stripped out:
// Inside one gateway's adapter. The vault fills the {{card.*}} fields.
async charge(req: ChargeRequest): Promise<ChargeResult> {
const res = await this.vault.forward({
token: req.cardToken,
target: this.endpoint('/payments'),
headers: { 'Idempotency-Key': req.paymentId, ...this.auth(req.merchantId) },
body: {
reference: req.paymentId,
amount: req.amount.cents.toString(),
currency: req.amount.currency,
card: { number: '{{card.number}}', expiry: '{{card.expiry}}' },
},
});
return this.toResult(res);
}
This keeps the gateway-specific code in your main platform, where your team can change it quickly, and keeps the vault generic. The vault doesn’t need a release when you add a gateway; it needs an allow-list entry for the new gateway’s endpoint, so that it will only ever forward card data to destinations you’ve approved.
Some teams go the other way and use the vault only to provision a gateway token for each card at each gateway, then charge with those tokens directly. That keeps the vault off the hot path of every payment, at the cost of a provisioning step and a token mapping per gateway. Both work; the choice depends on how often you switch gateways and how many you run.
Routing and failover between gateways
With one interface, choosing a gateway becomes a function, not a code path. In most SaaS platforms the first rule is simple: each customer has their own merchant account at the gateway they signed up with, and their payments go there. After that come rules for card types, currencies, cost and availability.
Failover is where teams get hurt. It is only safe to send a payment to a second gateway when you know the first one did not take it:
- A clear decline or a validation error means the first gateway didn’t charge the card. Whether to try another gateway is a business decision, and for a genuine decline (insufficient funds, card reported stolen) the answer is usually no.
- A connection refused before the request was sent is safe to retry elsewhere.
- A timeout or a 5xx after the request was sent is ambiguous. The charge may have gone through. Look it up first, and only fail over once the first gateway confirms there is no payment.
async function chargeWithFailover(req: ChargeRequest, route: PaymentGateway[]) {
for (const gateway of route) {
// safely() turns errors into results; a request that never left us
// becomes a decline with reason 'not_sent'.
const result = await safely(() => gateway.charge(req));
if (result.status === 'declined' && result.reason === 'not_sent') continue;
if (result.status !== 'unknown') return { gateway, result };
const found = await gateway.lookup(req.paymentId);
if (found) return { gateway, result: found };
if (!(await gateway.isSafeToAbandon(req.paymentId))) {
return { gateway, result }; // leave it unknown; reconciliation decides
}
}
const result = { status: 'declined', reason: 'no_route', retryable: false } as const;
return { gateway: null, result };
}
Not every gateway can answer “is there a payment with this reference?” immediately, so isSafeToAbandon is deliberately conservative. A payment left in unknown is a manageable state: a background job keeps checking, the webhook usually settles it, and reconciliation catches the rest. A payment charged twice at two gateways is a refund, an apology and sometimes a chargeback.
In Australia there are local details the routing layer often ends up owning, such as dual-network debit cards, where the acquirer may route through eftpos or the international scheme, and surcharging, which the Reserve Bank regulates and has been reviewing. Keep those rules in the routing and pricing layer, in one place, rather than spread through checkout code.
Webhooks and idempotency
Gateways tell you what happened asynchronously, through webhooks: a payment settled, a refund completed, a chargeback opened. Webhooks arrive late, out of order and more than once. The handler has to be safe under all three.
What I do:
- Verify, store, acknowledge. Check the signature, write the raw event to a table with a unique constraint on the gateway and its event ID, and return 200. Do nothing else in the request.
- Process from the table, asynchronously. A worker picks up stored events. If the same event arrives twice, the unique constraint means it is stored once and processed once.
- Only move forward. A payment’s status follows a state machine. An older event that arrives after a newer one can’t move a payment backwards, from refunded to approved, say. When an event doesn’t fit, fetch the current state from the gateway rather than guess.
create table gateway_event (
gateway text not null,
event_id text not null,
received_at timestamptz not null default now(),
payload jsonb not null,
processed_at timestamptz,
primary key (gateway, event_id)
);
Idempotency runs the length of the flow. The pay button carries a payment ID generated when the checkout loads, so a double-click submits the same payment twice, not two payments. That ID is the key for our own payment record, the idempotency key sent to the gateway where the gateway supports one, and the merchant reference everywhere else. If a gateway has no idempotency support, the reference plus a lookup before any retry does the same job.
This is the same discipline as posting to a ledger, one step earlier. If the money then lands in a trust account, the ledger side is covered in designing a trust accounting ledger that can’t double-debit.
What a separate vault does to your PCI DSS scope
This is the part most often oversold, so it’s worth being precise. What follows is how the standard is generally applied; your Qualified Security Assessor, or your acquirer if you self-assess, makes the actual call on your scope, and the details depend on the version of PCI DSS and of each questionnaire in force when you’re assessed.
What gets smaller. If the main platform never stores, processes or transmits card data, can’t reach the vault’s data, and is properly segmented from it, most of it can sit outside the cardholder data environment. That is the point of the design: your largest, fastest-changing system no longer has to meet every PCI DSS requirement, and changes to it don’t each need to be weighed against the standard.
What stays in scope. The vault, fully. Anything connected to it or able to affect its security, such as the identity provider its admins log in with, the pipeline that deploys it, and the logging it ships to. And, to a lesser degree, the pages that embed the card form, because a compromised page could replace the vault’s frame with a fake. The requirements for those pages have changed between recent revisions of the self-assessment questionnaires, so check the current wording rather than an old blog post.
Which questionnaire. For merchants, the self-assessment questionnaire depends on how card data reaches the payment processor. SAQ A covers e-commerce where all card data handling is outsourced to compliant third parties, typically through an iframe or redirect. SAQ A-EP covers sites that don’t receive card data themselves but can affect the security of the payment, for example by building the card form into their own page with a provider’s script. SAQ D covers merchants that don’t fit a narrower questionnaire.
If your platform runs its own vault on behalf of customers, you are likely a service provider, and service providers validate with the service-provider version of SAQ D or a Report on Compliance. Your customers may in turn qualify for a lighter questionnaire because your vault and your hosted form are doing the work. Which of these applies to you is exactly the question to take to an assessor, with the architecture diagram in hand.
Segmentation has to be proven. Network segmentation isn’t strictly required by PCI DSS, but if you rely on it to keep systems out of scope, it has to be tested with penetration testing, and service providers have to test it more often than merchants. Design the network so that test is easy to pass: the main platform can call the vault’s token and forwarding API, and nothing else.
Card data leaks. The quickest way to pull a system back into scope is a card number in a log. Mask card-like numbers in logs and error reports at the platform edge, and alert when one turns up. A support ticket with a pasted card number is in scope too, so your support tooling needs a plan.
A sensible order to build it in
You don’t need the whole design on day one. The order I’d recommend for a SaaS team adding payments:
- Start with one gateway and its hosted form. Use the gateway’s own tokens. Keep your own payment records, with your own payment IDs and idempotency, from the start.
- Put the interface in early. Even with one gateway, write your product against your own payment interface. It costs little and makes the second gateway an adapter, not a rewrite.
- Add the vault when you need portability. The moment you need stored cards to work at more than one gateway, introduce the vault, either your own or a third-party one, and migrate stored cards to it with each gateway’s help.
- Add routing and failover last, once you have the payment states, lookups and reconciliation to make it safe.
If you’re adding payments to your product or adding another gateway, my payments integration page describes how I work on it with teams.