MODRACXKENNETH D'SILVA

← Archive & Insights

Integrating Secure Payment Gateways for Ecommerce

Forty-one customers charged twice because a UUID was generated inside a retry loop. What integration method, tokenisation, 3-D Secure and webhooks actually demand of you.

By Kenneth D'SilvaReading Time: 23 min readCategory: Security & Compliance

1. Forty-One Customers Charged Twice On A Tuesday

A tiles and stone retailer rang me at 08:40 on a Tuesday in November because their support inbox had nineteen emails from customers saying they had been charged twice. By the time I had the logs open it was forty-one.

The cause took about twenty minutes to find and it is worth describing precisely, because I have now seen the same shape of bug at four different merchants. Their checkout called the gateway's authorisation endpoint with a 10-second HTTP timeout. The gateway had a slow morning — nothing dramatic, p99 response times drifted from about 900ms to around 11 seconds — and their client started timing out. The timeout was handled by a retry. The retry did not carry an idempotency key, because the key was generated inside the retry loop rather than outside it. So each retry created a fresh authorisation, and the customer got charged for every attempt that eventually landed.

The gateway did nothing wrong. The card networks did nothing wrong. A single misplaced line of code — a UUID generated in the wrong scope — cost about £3,400 in refunds, two days of support time, and a chargeback rate spike that took a quarter to fall back out of the rolling window.

What I want to get across in this article is that integrating a payment gateway is not mostly a cryptography problem. The cryptography is handled for you and has been for years. It is a distributed systems problem wearing a compliance costume, and nearly every expensive failure I have investigated came from one of three places: not understanding what your integration method does to your compliance scope, not treating the network as unreliable, or treating a synchronous API response as the truth about whether money moved.

I am going to stay off the ground covered in the piece on PCI DSS scope and assessment — the SAQ types, what an assessor asks for, how segmentation works. Assume that here and read it alongside. This one is about the engineering decisions: which integration to pick, what tokenisation actually buys you, how to get 3-D Secure right, how to verify webhooks properly, and how to fail without charging anyone twice.

2. Three Ways To Collect A Card, And They Are Not Close

Every provider markets four or five integration products. Underneath there are three shapes, and the shape determines everything else.

Full redirect to a hosted payment page

The customer leaves your domain entirely. They land on the provider's page — checkout.stripe.com, an Adyen hosted checkout, a Worldpay page — pay there, and return to your confirmation URL. Your servers never see a card number and your page never contains a card field.

This is the safest position available and it is the one I recommend to any merchant who does not have a specific reason to do otherwise. Your checkout page cannot be manipulated into skimming cards, because there is nothing to skim. An attacker who fully compromises your front end can redirect customers elsewhere, which is bad, but they cannot silently collect card details while the real flow continues.

Embedded fields in provider-controlled iframes

Stripe Elements, Braintree Hosted Fields, Adyen's Drop-in and Components. The card inputs are real iframes served from the provider's origin, positioned inside your checkout layout so they look like your own inputs. The browser's same-origin policy means your JavaScript cannot read what the customer types.

This is where most merchants land and it is a reasonable place to be. The customer stays on your domain, you control the layout, and the card data goes from the browser directly to the provider. The catch is that your page's integrity now matters enormously: an attacker who can inject script into your checkout can overlay a convincing fake form on top of the real iframe and the customer cannot tell the difference. That is the entire Magecart playbook.

Direct API — card data through your server

You collect the PAN in your own form and post it to the gateway server-side. Full control over the flow, full control over the fields, and full responsibility for everything the card touches on the way through.

I have built this twice in my career and would not do it again for a normal retailer. The situations where it is defensible are narrow: a merchant of record with an existing certified environment, or a payment facilitator whose business is payments. If you are selling furniture, the answer is no.

Hosted redirectEmbedded iframe fieldsDirect API
Card data touches your serverNeverNeverAlways
Card data touches your JavaScriptNeverNeverYes
Typical SAQAA-EPD
Skimming risk from your own pageRedirect tampering onlyReal — overlay attacksTotal
Control over checkout UXLimited to their themingNear totalTotal
Effort to implementDays1–3 weeksMonths, plus certification
Effort to maintain annuallyMinimalScript inventory, CSP, SRISubstantial and permanent
Apple Pay / Google PayHandled for youHandled, some wiringYou build it

There is a fourth thing people ask about, which is a redirect that is styled to look like your site and returns immediately. Functionally that is a hosted page and it carries the hosted page's scope. What it does not do is make the domain in the address bar say your name, which is the objection everybody actually has.

3. The Conversion Argument, And Whether It Survives Measurement

The reason merchants reject hosted pages is always conversion. Sending someone to a different domain mid-checkout is claimed to cost sales. I believed this uncritically for years and then measured it three times.

What I found, on a tiles and stone retailer with about 4,000 orders a month: hosted redirect converted 1.3% worse than embedded fields on desktop, and 0.4% better on mobile. The mobile result surprised me and it has a plausible explanation — the hosted page was a single-purpose form with native keyboard handling and no layout shift, and their embedded checkout had a habit of jumping when the iframe finished loading.

On a second client, a supplements brand, the gap was 2.9% in favour of embedded and it was consistent. So the honest answer is that it depends, and what it depends on is mostly how good your own checkout is. If your embedded checkout is well built, it wins by a small amount. If it is not, the provider's single-purpose page probably beats it.

The way to settle it is a split test with orders as the metric, not a debate. And run it for at least three weeks — payment conversion has a strong day-of-week pattern and a fortnight is not enough to see through it.

One number that does not show up in that test and should be in the decision: the annual cost of keeping an A-EP-scoped checkout page defensible. Script inventory, subresource integrity, a content security policy that is actually enforcing, a tamper-detection check on the page. That is real engineering time every year, forever. If the conversion difference is 1%, work out what 1% of your gross profit is and compare it against a few days of engineering per quarter, and the decision often flips.

4. What A Token Is, And What It Is Not

Tokenisation is the mechanism underneath every one of those integrations, and it is described so loosely that people end up with wrong mental models.

A token is an identifier that the provider maps back to a stored card inside their vault. It is not encrypted card data. It is not reversible by you, or by anyone who steals your database, or by the provider's other customers. It carries no cryptographic material. It is a foreign key into somebody else's table.

Two distinctions matter operationally and get conflated constantly.

Single-use versus multi-use. A token produced by a card field for one payment is typically single-use and expires in minutes. A token representing a saved payment method is long-lived and reusable. Storing a single-use token and trying to charge it next month fails in a way that is confusing to debug, because the error usually says the token does not exist rather than that it expired.

Provider tokens versus network tokens. A provider token lives in your gateway's vault and is worthless if you change providers. A network token is issued by Visa or Mastercard themselves and follows the card through reissues and expiry updates. Network tokens measurably improve authorisation rates on recurring charges — I have seen 1.5 to 3 percentage points on subscription billing — because the token updates when the customer's card is replaced and yours does not.

The commercial consequence of the first distinction is vendor lock-in, and it is worth naming out loud. If you have 200,000 saved cards in Adyen's vault and you want to move to Stripe, you cannot export them yourself. What you can do is request a provider-to-provider vault migration, which both major providers support, which requires PCI-compliant handling on both sides and a project plan. It is possible and it is not a weekend. Ask about it before you sign, not after.

// What the merchant database should actually hold. Note what is absent:
// no PAN, no CVV ever, no expiry we are responsible for keeping current.
//
// CREATE TABLE payment_methods (
//   id              uuid PRIMARY KEY,
//   customer_id     uuid NOT NULL REFERENCES customers(id),
//   provider        text NOT NULL,          -- 'stripe' | 'adyen'
//   provider_token  text NOT NULL,          -- opaque; useless if leaked
//   brand           text,                   -- 'visa'  — display only
//   last4           char(4),                -- display only, permitted
//   exp_month       smallint,               -- display only; provider is truth
//   exp_year        smallint,
//   is_network_token boolean DEFAULT false,
//   created_at      timestamptz DEFAULT now()
// );

async function savePaymentMethod(customerId, setupIntentId) {
  const si = await stripe.setupIntents.retrieve(setupIntentId, {
    expand: ['payment_method'],
  });

  if (si.status !== 'succeeded') {
    // Do NOT store an unconfirmed method. It will decline later and the
    // customer will blame you rather than their bank.
    throw new Error(`SetupIntent not confirmed: ${si.status}`);
  }

  const pm = si.payment_method;
  return db.paymentMethods.insert({
    customer_id: customerId,
    provider: 'stripe',
    provider_token: pm.id,
    brand: pm.card.brand,
    last4: pm.card.last4,
    exp_month: pm.card.exp_month,
    exp_year: pm.card.exp_year,
  });
}

Storing last4 and the brand is fine and expected — that is truncated data, permitted for display, and it is why every receipt you have ever seen shows four digits. Storing the CVV after authorisation is prohibited outright with no exceptions and no exemption process. I still find it in databases about once a year, usually in a debug column somebody added during an integration and never removed.

5. Saved Cards, And The Flag That Breaks Subscriptions

If you take one operational detail from this article, take this one, because it silently destroys recurring revenue and takes months to diagnose.

Card transactions are categorised by who initiated them. A customer-initiated transaction (CIT) is one where the cardholder is present and can respond to an authentication challenge. A merchant-initiated transaction (MIT) is one you trigger — a subscription renewal, a delayed capture, a usage-based charge — where nobody is at the keyboard.

Under SCA rules, the first transaction in a series must be authenticated with the customer present, and it must be flagged as establishing a mandate for future MITs. If it is not flagged, the issuer treats every subsequent renewal as an unauthenticated CIT and declines it, because there is no cardholder to challenge.

The symptom is a subscription business whose renewal decline rate is 20% or higher and who assume this is normal. It is not normal. Healthy MIT approval rates on established cards run in the mid-nineties.

// Step 1 — the customer-initiated transaction that establishes the mandate.
// setup_future_usage is the flag that makes every later renewal work.
const intent = await stripe.paymentIntents.create({
  amount: 2499,
  currency: 'gbp',
  customer: customerId,
  payment_method_types: ['card'],
  setup_future_usage: 'off_session',   // <-- the whole ballgame
  metadata: { order_id: orderId },
}, { idempotencyKey: `pi:${orderId}:auth` });

// Step 2 — months later, the merchant-initiated renewal. off_session tells
// the network no cardholder is present; confirm:true charges immediately.
async function chargeRenewal(subscription) {
  try {
    return await stripe.paymentIntents.create({
      amount: subscription.amount,
      currency: 'gbp',
      customer: subscription.customer_id,
      payment_method: subscription.payment_method_token,
      off_session: true,
      confirm: true,
    }, { idempotencyKey: `renew:${subscription.id}:${subscription.period}` });
  } catch (err) {
    if (err.code === 'authentication_required') {
      // The issuer wants the cardholder. You cannot force this off-session.
      // Email them a link that re-runs the flow as a CIT with 3DS.
      await sendAuthenticationRequiredEmail(subscription, err.raw.payment_intent.id);
      return null;
    }
    throw err;
  }
}

Note the idempotency key on the renewal: it includes the billing period. That is deliberate. If the renewal job runs twice for the same period — because a cron fired twice, or a worker was rescheduled — the second call returns the first result instead of charging again. Get that key wrong and you have built a machine for double-charging your most loyal customers on a schedule.

6. 3-D Secure, SCA, And The Friction Nobody Wants

Strong Customer Authentication is a regulatory requirement across the UK and EEA under PSD2, and 3-D Secure 2 is the protocol that satisfies it. If you sell into those markets you are already subject to it whether or not you have implemented it deliberately.

The mechanics are better than the reputation. In 3DS2 your provider's SDK collects a device profile — browser characteristics, screen dimensions, IP, and a set of transaction attributes — and sends it to the issuer's risk engine before any UI appears. The issuer scores it. Most of the time the answer is "fine, no challenge", and the customer sees nothing at all. That is the frictionless flow and it is the majority of traffic on established cards.

When the issuer wants proof, you get a challenge: a modal, usually an app push or a biometric prompt, occasionally an SMS code. Modern implementations render this in an iframe within your checkout rather than redirecting away, which is a real improvement over 3DS1.

Two things follow that matter commercially.

Liability shifts. A transaction authenticated through 3DS2 moves fraud chargeback liability to the issuer. If you are absorbing fraud losses on a category with high chargeback rates, forcing authentication on those transactions is a legitimate lever even at some conversion cost.

Exemptions exist and your provider's defaults may not use them. Low-value transactions under €30, transaction risk analysis exemptions based on the acquirer's fraud rate, trusted beneficiary listing where the customer has whitelisted you, and recurring transactions of a fixed amount. Each avoids a challenge. Each is a request, not a guarantee — the issuer can refuse and demand authentication anyway, in which case you have to be able to fall back.

// Requesting an exemption, and handling the case where the issuer says no.
// The fallback path is the part that gets skipped and then breaks in
// production at 2am on a Saturday.
const intent = await stripe.paymentIntents.create({
  amount: 1800,               // £18.00 — under the low-value threshold
  currency: 'gbp',
  payment_method: token,
  confirm: true,
  payment_method_options: {
    card: {
      request_three_d_secure: 'automatic',
      // Exemption requests are provider-specific; Adyen uses
      // additionalData.allow3DS2 plus a threeDSRequestorChallengeInd.
    },
  },
}, { idempotencyKey: `pi:${orderId}:auth` });

switch (intent.status) {
  case 'succeeded':
    return completeOrder(orderId, intent);

  case 'requires_action':
    // Exemption refused, or risk engine wants proof. Hand the client
    // secret to the browser and let the SDK render the challenge.
    return { needsAuthentication: true, clientSecret: intent.client_secret };

  case 'requires_payment_method':
    // Hard decline. Do not retry the same card automatically.
    return { declined: true, reason: intent.last_payment_error?.decline_code };

  default:
    // Unknown state. Log loudly, do not assume failure, and let the
    // webhook reconcile it — the money may well have moved.
    logger.error({ intent_id: intent.id, status: intent.status }, 'unexpected intent status');
    return { pending: true };
}

That last branch is the one I care about. An unexpected status is not a failure. Treating it as a failure and letting the customer press pay again is how you end up back at forty-one double charges.

On the conversion cost of 3DS2: I measure abandonment on challenged transactions at roughly 5–9% depending on the issuer, against under 1% on frictionless ones. So the goal is not to avoid 3DS, it is to maximise the frictionless share, and the lever for that is sending a complete device profile. Merchants who skip the optional data fields see challenge rates two to three times higher, because the issuer's risk engine has nothing to work with and defaults to caution. Fill in the fields.

7. The Payment State Machine You Are Building Whether You Meant To Or Not

Every payment integration contains a state machine. The ones that go wrong are the ones where it was never written down, so it exists implicitly across four service boundaries and nobody can say what state an order is in.

The states that matter, and the transitions between them:

Pending — the customer has submitted and we do not yet know anything. Authorised — the issuer has reserved the funds but no money has moved. Captured — the money is moving to you. Failed — a definitive decline. Voided — an authorisation released before capture. Refunded — money returned after capture. And the state everyone forgets: Unknown, meaning we asked and did not get an answer.

The authorise-then-capture separation is worth using deliberately. Authorise at checkout, capture at dispatch. It matches the legal position in most of Europe — you should not take money for goods you have not shipped — and it turns a refund into a void, which costs you nothing and does not appear on the customer's statement at all. The constraint is that authorisations expire, typically after seven days, and if you dispatch on day nine the capture fails.

-- The payments table that makes reconciliation possible. Two things
-- do the heavy lifting: the unique constraint on the idempotency key,
-- and never deleting rows — state changes are appended, not updated.
CREATE TABLE payment_attempts (
  id                uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  order_id          uuid NOT NULL REFERENCES orders(id),
  provider          text NOT NULL,
  provider_ref      text,                -- payment intent / psp reference
  idempotency_key   text NOT NULL,
  amount_minor      bigint NOT NULL,
  currency          char(3) NOT NULL,
  state             text NOT NULL
                    CHECK (state IN ('pending','authorised','captured',
                                     'failed','voided','refunded','unknown')),
  decline_code      text,
  created_at        timestamptz NOT NULL DEFAULT now(),
  updated_at        timestamptz NOT NULL DEFAULT now(),

  -- One attempt per key, enforced by the database rather than by
  -- application code that a race condition can walk straight through.
  CONSTRAINT uniq_idempotency UNIQUE (provider, idempotency_key)
);

CREATE INDEX ON payment_attempts (order_id);
CREATE INDEX ON payment_attempts (provider, provider_ref);
-- Anything stuck in 'pending' or 'unknown' is a reconciliation job's input.
CREATE INDEX ON payment_attempts (state, created_at)
  WHERE state IN ('pending', 'unknown');

That partial index is small and it is the thing that lets you sweep for stuck payments cheaply every few minutes rather than scanning the whole table.

8. Idempotency, Done Properly

The bug at the start of this article was an idempotency failure, so it deserves its own treatment.

An idempotency key tells the gateway "this request and any identical retry of it are the same operation". The gateway caches the first response against the key — usually for 24 hours — and returns that cached response for any repeat. Both major providers support this and it is not optional.

Three rules, all of which I have watched somebody break.

Generate the key outside the retry loop. This is the bug from November. If the key is created where the request is made, every retry gets a new key and every retry is a new charge. Derive it once, from stable inputs, and pass it in.

Derive it deterministically from state, not randomly. A random UUID works until your worker crashes mid-request and a different worker picks the job up from the queue. That worker has no idea what UUID the dead one used. Hash the order ID and the operation phase instead, and any worker that picks up the job derives the same key.

Scope it to the operation, not the order. Authorising, capturing and refunding the same order are three operations. Reusing one key across them means the second call returns the first call's cached response, and you will spend a long afternoon working out why your capture returned an authorisation object.

import hashlib, time, logging

def idempotency_key(order_id: str, phase: str, attempt_window: str = "") -> str:
    """Deterministic, stable across process restarts and worker handoffs.

    phase: 'auth' | 'capture' | 'refund:<refund_id>'
    attempt_window: leave empty for a single logical attempt. Set it to a
      period identifier for genuinely repeatable operations, e.g. a
      subscription's billing period, so period N+1 gets its own key.
    """
    raw = f"{order_id}|{phase}|{attempt_window}"
    return hashlib.sha256(raw.encode()).hexdigest()


def authorise_with_retries(gateway, order, max_attempts=4):
    key = idempotency_key(order.id, "auth")      # derived ONCE, outside the loop
    delay = 0.5
    for attempt in range(max_attempts):
        try:
            return gateway.authorise(
                amount=order.amount_minor,
                currency=order.currency,
                token=order.payment_token,
                idempotency_key=key,             # identical on every retry
                timeout=25,                      # generous: see below
            )
        except (gateway.Timeout, gateway.ConnectionError) as exc:
            logging.warning("auth attempt %d failed for %s: %s",
                            attempt + 1, order.id, exc)
            if attempt == max_attempts - 1:
                # Do NOT report failure to the customer. We do not know.
                mark_payment_unknown(order.id, key)
                raise PaymentIndeterminate(order.id)
            time.sleep(delay)
            delay *= 2                            # 0.5s, 1s, 2s

The 25-second timeout is deliberate and counter-intuitive. Short timeouts feel defensive and they are the opposite: a request that times out at 5 seconds may well still be processing at the gateway, and now you have an authorisation you do not know about. Set the client timeout comfortably above the gateway's own p99 and let slow responses be slow. Every timeout is a payment whose state you have to reconcile later.

9. Webhooks Are The Source Of Truth

The synchronous API response tells you what the gateway believed at the moment it replied. Webhooks tell you what actually happened. When those disagree — and they do, for redirects, bank transfers, delayed captures, disputes, and every timeout — the webhook wins.

This has an architectural consequence people resist: fulfilment should be triggered by the webhook, not by the customer landing on your confirmation page. The customer's browser is not a reliable messenger. They close the tab, lose signal in a lift, or get a bank app redirect that returns them to the wrong URL. If your order only becomes real when the browser comes back, some orders never become real.

Verifying the signature without destroying the payload

Every serious provider signs webhook payloads with an HMAC over the raw request body. You recompute it with your shared secret and compare in constant time. The near-universal implementation bug is that a JSON body-parser has already consumed and re-serialised the request before your handler sees it, and the re-serialisation changes whitespace or key ordering, so the signature never matches.

const express = require('express');
const crypto = require('crypto');
const app = express();

// The webhook route is mounted BEFORE any global express.json(), and uses
// express.raw so req.body is the exact bytes that were signed.
app.post('/webhooks/psp',
  express.raw({ type: 'application/json' }),
  async (req, res) => {
    const sigHeader = req.get('x-psp-signature') || '';
    const timestamp = req.get('x-psp-timestamp') || '';

    // Reject stale payloads: without this, a captured request can be
    // replayed at any point in the future.
    const ageSeconds = Math.abs(Date.now() / 1000 - Number(timestamp));
    if (!timestamp || ageSeconds > 300) {
      return res.status(400).send('stale or missing timestamp');
    }

    const expected = crypto
      .createHmac('sha256', process.env.PSP_WEBHOOK_SECRET)
      .update(`${timestamp}.`)
      .update(req.body)               // Buffer, untouched
      .digest('hex');

    const a = Buffer.from(expected, 'utf8');
    const b = Buffer.from(sigHeader, 'utf8');
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      // Log it. A sudden run of these is either a rotated secret or
      // somebody probing the endpoint.
      return res.status(400).send('bad signature');
    }

    const event = JSON.parse(req.body.toString('utf8'));

    // Persist first, process later. A 200 must mean "durably received",
    // not "fully handled" — otherwise slow downstream work causes the
    // provider to time out and redeliver, multiplying the work.
    const stored = await db.webhookEvents.insertIgnoreDuplicate({
      provider: 'psp',
      event_id: event.id,          // UNIQUE — this is your replay guard
      type: event.type,
      payload: event,
    });

    if (stored.inserted) await eventQueue.publish(event.id);
    res.status(200).json({ received: true });
  });

// Everything else can parse JSON normally.
app.use(express.json());

Four things in that handler are doing real work. The raw body preserves the signed bytes. The timestamp check kills replay. The constant-time comparison prevents a timing oracle, which is admittedly a theoretical concern here but costs nothing. And the unique constraint on event_id means a redelivered webhook is stored once and processed once — which matters because every provider redelivers, and "at least once" is the only guarantee any of them offer.

Return 200 as soon as the event is durably stored. If you do the fulfilment work inline and it takes 40 seconds, the provider times out, marks delivery failed, and sends it again — and now two workers are fulfilling the same order concurrently. Persist, acknowledge, process asynchronously.

10. Failing Without Charging Anyone Twice

The hard part of payments is not the success path. It is the set of states where you genuinely do not know what happened.

The rule I work to: a timeout is not a failure. A timeout is an unknown. The only safe responses to an unknown are to retry with the same idempotency key, or to query the gateway for the current state of that key, or to mark it for reconciliation. What you must never do is tell the customer it failed and offer a retry button, because if the original request landed you have now taken two payments and the customer initiated the second one themselves.

What the customer should see instead: "We are confirming your payment — you will get an email within a few minutes. Do not pay again." Then confirm asynchronously. It is a worse-looking checkout and it is correct.

The reconciliation job is the safety net and it is the piece most merchants only write after their first incident.

"""reconcile.py — every 5 minutes, resolve payments whose state we do
not know. This job is boring, it is unglamorous, and it is the single
highest-value thing in the payments codebase."""
import datetime as dt

STUCK_AFTER = dt.timedelta(minutes=2)
GIVE_UP_AFTER = dt.timedelta(hours=24)

def reconcile(db, gateway, alerts):
    now = dt.datetime.now(dt.timezone.utc)
    rows = db.query("""
        SELECT id, order_id, idempotency_key, provider_ref, amount_minor,
               created_at
          FROM payment_attempts
         WHERE state IN ('pending', 'unknown')
           AND created_at < %s
         ORDER BY created_at
         LIMIT 500
    """, (now - STUCK_AFTER,))

    for row in rows:
        # Ask the gateway what it thinks. Searching by idempotency key
        # works even when we never received a reference at all — which is
        # exactly the case that matters.
        remote = gateway.lookup(idempotency_key=row["idempotency_key"]) \
                 or gateway.lookup(reference=row["provider_ref"])

        if remote is None:
            if now - row["created_at"] > GIVE_UP_AFTER:
                # 24h with no trace at the gateway means it never landed.
                db.set_state(row["id"], "failed", reason="no_remote_record")
                alerts.info(f"payment {row['id']} never reached gateway")
            continue

        if remote.amount_minor != row["amount_minor"]:
            # Never auto-resolve an amount mismatch. Stop and page someone.
            alerts.critical(
                f"AMOUNT MISMATCH order={row['order_id']} "
                f"local={row['amount_minor']} remote={remote.amount_minor}")
            continue

        db.set_state(row["id"], remote.state, provider_ref=remote.reference)
        if remote.state in ("authorised", "captured"):
            fulfilment.enqueue(row["order_id"])
        elif remote.state == "failed":
            notifications.payment_failed(row["order_id"], remote.decline_code)

The amount mismatch branch has fired for me exactly once, on a currency-rounding bug in a multi-currency checkout, and it saved a genuinely nasty situation. Never let a reconciliation job silently accept a different amount than the one you recorded.

Alongside that, run a daily settlement reconciliation against the provider's settlement file: every captured payment in your database should appear in their report, and every line in their report should map to an order. Discrepancies are usually refunds processed in the provider's dashboard by a support agent who did not know there was an API. That is a process problem and finding it takes a query, not an investigation.

11. Declines Are Not All The Same

A decline code is information and most merchants throw it away, showing every customer the same "payment failed" message.

The distinction that matters is soft versus hard. A soft decline — insufficient funds, issuer temporarily unavailable, do not honour — may succeed later or on a different day. A hard decline — stolen card, invalid account, pick-up card — will never succeed and retrying is at best pointless and at worst looks like card testing to the network.

For subscription billing, a retry schedule against soft declines recovers a meaningful share of failed renewals. What works in my experience: retry at day 1, day 3 and day 7, ideally at a different hour each time, and stop. Retrying daily for a fortnight annoys the issuer, raises your decline ratio, and can get your merchant account flagged. Some providers will tell you the optimal retry window per issuer; use it if offered.

For one-off checkout declines, tell the customer something useful. "Your bank declined this — they may have sent you an approval prompt in your banking app" recovers a surprising number of transactions, because pending app approvals are extremely common and customers do not think to check.

12. Choosing A Provider, By What Actually Matters

Provider selection conversations tend to be about the headline rate. The headline rate is one of about six things that determine what you pay and what you can build.

Authorisation rate. A provider with a 2% better approval rate on the same traffic is worth far more than 0.1% off the transaction fee. Ask for their approval rate on merchants of your size in your category, and ask what it looks like on card-on-file MITs specifically.

Interchange++ or blended pricing. Blended is simpler and hides where the money goes. Interchange++ shows you the interchange, the scheme fee and the provider's margin separately. Above roughly £1m of processed volume, ask for interchange++, because it is the only way to know whether the provider's margin is what they told you.

Local payment methods where you sell. iDEAL in the Netherlands, Bancontact in Belgium, Blik in Poland, Klarna in the Nordics. If you sell into a market where 60% of ecommerce runs on a method you do not accept, no amount of card optimisation compensates.

How they handle disputes. Is there an API for submitting evidence, or is it a portal where somebody uploads PDFs? At scale this is the difference between an automated job and a part-time role.

Vault portability. Covered above and worth repeating: ask about migration before you sign.

Sandbox fidelity. Some sandboxes model 3DS challenge flows, partial captures, disputes and network tokens. Others return success to everything. You cannot test a payment integration properly against a sandbox that never says no, and you will discover the gaps in production.

What I would deprioritise: the marketing site's fraud tooling claims, the dashboard's appearance, and the availability of an official plugin for your platform. Plugins are usually the least maintained code either party owns, and I have replaced more of them than I have kept.

13. Testing A Payment Integration Honestly

The test suite people write covers the success path and one decline. The failures that cost money live elsewhere.

What I insist on before a payment integration goes live: a test that asserts a retried request with the same idempotency key produces exactly one charge; a test that a webhook delivered twice fulfils once; a test that a webhook with a tampered body is rejected; a test that a 3DS challenge response is handled without losing the order; and a test that a gateway timeout leaves the order in a recoverable state rather than a failed one.

import pytest

def test_retry_with_same_key_charges_once(fake_gateway, order):
    """The November bug, as a test. If this passes you have avoided the
    most expensive class of payment defect there is."""
    fake_gateway.fail_next(times=2, error="timeout")   # two timeouts, then OK
    authorise_with_retries(fake_gateway, order)
    assert fake_gateway.distinct_charges(order.id) == 1
    assert len(fake_gateway.requests_received) == 3     # all with one key
    assert len({r.idempotency_key for r in fake_gateway.requests_received}) == 1


def test_duplicate_webhook_fulfils_once(client, signed_payload, fulfilment):
    body, headers = signed_payload("payment.captured", order_id="A-1001")
    assert client.post("/webhooks/psp", data=body, headers=headers).status_code == 200
    assert client.post("/webhooks/psp", data=body, headers=headers).status_code == 200
    assert fulfilment.calls_for("A-1001") == 1


def test_tampered_webhook_rejected(client, signed_payload):
    body, headers = signed_payload("payment.captured", order_id="A-1001")
    tampered = body.replace(b'"amount_minor":2499', b'"amount_minor":1')
    resp = client.post("/webhooks/psp", data=tampered, headers=headers)
    assert resp.status_code == 400


def test_timeout_leaves_order_recoverable(fake_gateway, order, db):
    fake_gateway.fail_next(times=99, error="timeout")
    with pytest.raises(PaymentIndeterminate):
        authorise_with_retries(fake_gateway, order)
    # Crucially NOT 'failed' — we do not know, and reconciliation must run.
    assert db.payment_state(order.id) == "unknown"

Also: run a chaos exercise against staging before launch. Block the gateway's hostname at the firewall mid-checkout and see what the customer experiences. Add 30 seconds of latency and see whether anything gives up in a way that loses an order. Send a malformed webhook. These take an afternoon and they find things a unit test cannot.

14. A Migration That Mostly Went Well

A supplements retailer, about 22,000 orders a month, 40% of revenue on subscriptions, moving from a legacy gateway with a direct API integration to embedded fields with a modern provider. The driver was the legacy integration having put them in SAQ D scope and their acquirer starting to ask harder questions.

The plan was three phases over ten weeks: new provider live for new customers on embedded fields, vault migration for the 61,000 saved cards, then decommission the old integration.

What the numbers looked like at the end.

MetricBeforeAfter 10 weeks
PCI scopeSAQ DSAQ A-EP
One-off checkout auth rate91.2%94.6%
Subscription renewal success78.4%93.1%
Checkout conversion2.61%2.68%
Support tickets tagged 'payment'~140 / month~50 / month
Effective processing cost1.94%1.71%

The renewal number is the interesting one. Fifteen points is enormous and it was not the new provider being cleverer — it was that the old integration had never flagged the initial transactions as establishing an MIT mandate, so a large share of renewals were being declined as unauthenticated. Nobody had diagnosed this in four years. They had built a whole dunning email programme around a bug.

Now what went wrong, because two things did.

The vault migration moved 61,000 tokens and 3,800 of them failed to map, mostly cards that had expired or been reissued since their last use. We had planned for this and had a re-authentication email flow ready. What we had not planned for was that about 400 of those customers had active subscriptions renewing within the following week, so they got a "please update your payment details" email and a failed renewal in the same 48 hours, which reads as an error even though both were correct. We should have suppressed renewals for the unmapped cohort for a week and dealt with them individually. That was avoidable and it was my oversight.

The second problem was subtler. During the parallel-running phase, both integrations were writing to the same orders table, and the new one wrote provider references in a different format. A reporting query that parsed those references to work out settlement dates started producing wrong figures, and finance did not notice for eleven days because the totals were right and only the daily split was wrong. The lesson I took: when two systems write to the same table during a migration, add an explicit provider column and make every downstream query filter on it from day one, rather than assuming references are self-describing.

The thing I would do differently at the level of sequencing: we migrated the vault before we had run a full month of the new integration on new customers. Doing it the other way round would have surfaced the token mapping issues on a smaller population.

15. Questions I Get Asked

"Can we store the card ourselves so we are not locked in?" Technically yes, practically no. Storing PANs puts you in SAQ D, requires encryption and key management you will have to defend to an assessor, and the lock-in problem is better solved by asking about vault migration in the contract.

"Is Apple Pay more secure than a card form?" Yes, meaningfully. The merchant never receives the real PAN — a device-specific token with a cryptogram is used instead — and biometric authentication satisfies SCA, so those transactions are frictionless by construction. Wallet payments also convert better on mobile in every dataset I have seen. If you have not enabled them, that is the highest-return payment work available to you.

"Should we use multiple gateways?" Below a few million in volume, no — the operational complexity exceeds the benefit. Above that, a second provider gives you failover and a negotiating position, and the routing logic is a genuine engineering project rather than a config toggle. Do not build it because you read that large merchants do.

"Our provider says their plugin is PCI compliant." A plugin cannot be compliant; an environment is. What they mean is the plugin does not route card data through your server, which is a useful fact and not the same claim. Read the integration and confirm the fields are provider-origin iframes rather than styled inputs your page can read.

"Do we need 3DS if we only sell in the US?" Not by regulation, and it may still be worth it selectively for high-risk orders because of the liability shift. Run it as a rule on your fraud engine rather than on everything.

"How do we handle partial refunds and partial captures?" Both are normal and both need their own idempotency scope — key them by the refund identifier, not the order. The specific trap is a partial capture that leaves an authorisation open on the remainder; make sure you either capture the rest or void it, or your customer sees a pending amount on their statement for a week and rings you about it.

"Should fulfilment happen on the confirmation page or the webhook?" The webhook. Always. The confirmation page is a user interface, not an event.

16. What I Would Do First

Check where your idempotency keys are generated. If any of them are created inside a retry loop or inside the function that makes the HTTP call, fix that today — it is a fifteen-minute change and it is the highest-severity bug in this article.

Find out what your client timeout to the gateway is. If it is under 15 seconds, raise it, and make sure a timeout marks the payment unknown rather than failed.

Write the reconciliation job if you do not have one. Query for anything pending or unknown older than two minutes, ask the gateway what it thinks, and resolve. It is under a hundred lines and it will catch problems you do not currently know you have.

Confirm your webhook handler reads the raw body, checks a timestamp, compares in constant time, and stores the event ID under a unique constraint. Then send it a tampered payload and confirm it says no.

If you sell subscriptions, pull your renewal success rate. If it is below 90%, check whether the initial transactions carry an MIT mandate flag. This is the single most common silent revenue leak in the whole area and it is usually one parameter.

Then have the harder conversation about integration method. If you are on embedded fields, price the annual cost of keeping that page defensible — script inventory, CSP, integrity checks, tamper detection — and compare it honestly against the measured conversion difference of a hosted page. That measurement, not an assumption about what redirects do to conversion, is what should decide it.

None of this is difficult work. It is careful work, which is a different thing, and the difference shows up on a Tuesday morning when the gateway has a slow hour and you find out whether your integration was built by someone who expected the network to be reliable.

Suggested & Related Reading

Explore further technical engineering guides and architectural blueprints from Kenneth D'Silva: