Skip to content

Card

Accept credit and debit cards with automatic 3DS authentication, PCI-compliant tokenisation, and built-in fraud prevention.

Overview

Card payments are automatically included in the drop-in and appear as the default expanded payment method. The drop-in handles all card input fields, validation, tokenisation, and 3D Secure authentication automatically.

Key benefits

  • New card entry: Secure card number, expiry, and CVV input
  • Card-on-file: Stored cards for returning customers (requires onGetShopper)
  • 3D Secure: Automatic 3DS authentication when required
  • Address verification: Optional AVS for fraud prevention
  • Validation: Automatic validation of card number (Luhn), expiry date, and CVV

How it works

When a customer pays with a card:

  1. The customer enters their card details (number, expiry, CVV, name). Real-time validation provides instant feedback.
  2. The customer clicks submit.
  3. The card is securely tokenised (never touches your server).
  4. The system evaluates whether 3DS is required.
  5. If needed, 3DS authentication occurs.
  6. The transaction is processed.
  7. Your onSuccess callback fires.

Drop-in handles tokenisation, 3DS, and all card field configuration automatically.

Configuration

Card payments inherit all settings from methodConfig.global. There are no card-specific configuration options - the card object remains empty.

Global settings that affect card

The following properties are available in methodConfig.global and apply to card payments:

Property Description
acceptedCardNetworks
string[]
Which card brands to accept (Visa, Mastercard, etc.). Falls back to session.allowedFundingTypes.cardSchemes
allowedCardFundingSource
string[]
Which funding types to accept (Credit, Debit, Prepaid)
allowedIssuerCountryCodes
string[]
Restrict cards to specific issuer countries (ISO country codes)
onGetConsent
(paymentMethod) => boolean
Controls whether to show consent checkbox for saving cards
onCancel
(paymentMethod, data) => void
Called when user cancels card payment (e.g., closes 3DS window)

Card-specific configuration

Card payments use an empty card object in methodConfig:

methodConfig: {
  global: {
    // Card settings go here
  },
  card: {
    // Empty - all configuration is in global
  }
}

Complete example

This example shows a full card configuration using global settings:

import PaymentMethod from '@pxpio/web-components-sdk/src/components/checkoutDropInComponents/types/PaymentMethod';

methodConfig: {
  global: {
    // Card settings
    acceptedCardNetworks: ['Visa', 'Mastercard', 'American Express'],
    allowedCardFundingSource: ['CREDIT', 'DEBIT'],
    allowedIssuerCountryCodes: ['US', 'GB', 'CA'],
    
    // Show consent checkbox for card to enable card-on-file
    onGetConsent: (paymentMethod: PaymentMethod) => {
      return paymentMethod === PaymentMethod.Card || paymentMethod === PaymentMethod.Paypal;
    },
    
    // Handle 3DS window cancellation
    onCancel: (paymentMethod: PaymentMethod, data: any) => {
      if (paymentMethod === PaymentMethod.Card) {
        console.log('Card payment cancelled', data);
        // Track analytics
        trackEvent('card_cancelled', {
          timestamp: Date.now()
        });
      }
    }
  },
  card: {
    // Empty - inherits from global
  }
}

Common scenarios

Payment intents

Choose the appropriate intent for your use case:

import IntentType from '@pxpio/web-components-sdk/src/basePxpCheckout/types/IntentType';

// Physical goods (authorise now, capture later)
transactionData: {
  intent: { card: IntentType.Authorisation }  // Capture when shipped
}

// Digital goods (immediate capture)
transactionData: {
  intent: { card: IntentType.Purchase }
}

// Card verification (no charge)
transactionData: {
  amount: 0.00,
  intent: { card: IntentType.Verification }
}

Implementation

Card payments work through the standard Drop-in implementation:

import CheckoutDropIn from '@pxpio/web-components-sdk/src/checkoutDropIn/CheckoutDropIn';
import IntentType from '@pxpio/web-components-sdk/src/basePxpCheckout/types/IntentType';
import { BaseSubmitResult } from '@pxpio/web-components-sdk/src/checkoutDropIn/types/BaseSubmitResult';
import BaseSdkException from '@pxpio/web-components-sdk/src/types/sdkExceptions/BaseSdkException';

// Get session from backend
const response = await fetch('/api/create-session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
});

const result = await response.json();

if (!result.success || !result.data) {
  console.error('Failed to create session:', result.error);
  throw new Error('Session creation failed');
}

const sessionData = result.data;

// Initialise Drop-in
const checkoutDropIn = CheckoutDropIn.initialize({
  environment: 'test',
  session: sessionData,
  ownerId: 'MERCHANT-1',
  ownerType: 'MerchantGroup',
  transactionData: {
    currency: 'USD',
    amount: 99.99,
    entryType: 'Ecom',
    intent: {
      card: IntentType.Authorisation,
      paypal: IntentType.Authorisation
    },
    merchantTransactionId: crypto.randomUUID(),
    merchantTransactionDate: () => new Date().toISOString()
  },
  onGetShopper: () => Promise.resolve({ id: 'shopper-123' }),
  onSuccess: async (result: BaseSubmitResult) => {
    // CRITICAL: Verify on backend
    await verifyPaymentOnBackend(result);
  },
  onError: (error: BaseSdkException) => {
    console.error('Card payment failed:', error);
    alert(`Payment failed: ${error.message}`);
  }
});

// Mount the drop-in
checkoutDropIn.create('checkout-drop-in-container');

Session configuration (backend)

Create a session with card payments enabled:

// BACKEND: Create session with card enabled
const sessionRequest = {
  merchant: "MERCHANT-1",
  site: "SITE-1",
  sessionTimeout: 120,
  merchantTransactionId: crypto.randomUUID(),
  transactionMethod: {
    intent: {
      card: "Authorisation"
    }
  },
  amounts: {
    currencyCode: "USD",
    transactionValue: 99.99
  },
  allowedFundingTypes: {
    card: true  // Enable card payments
  },
  allowTransaction: true,
  serviceType: "CheckoutDropIn"
};

Handling responses

Card callback data

When a card payment succeeds, your onSuccess callback receives the standard result:

onSuccess: (result: BaseSubmitResult) => {
  console.log('Payment Details:');
  console.log('- System Transaction ID:', result.systemTransactionId);
  console.log('- Merchant Transaction ID:', result.merchantTransactionId);
  console.log('- Payment Method:', result.paymentMethod); // "Card"
  
  // Note: authenticationId is NOT included in the callback for card payments
  // Amount, currency, and other transaction details must be retrieved from backend
}

Unlike Google Pay and Apple Pay, card payments don't include authenticationId in the onSuccess callback, even when 3DS authentication was used. You must retrieve all transaction details (including authentication data, amount, currency, etc.) from your backend by querying the PXP API with the systemTransactionId.

Error handling

Handle card-specific errors:

import BaseSdkException from '@pxpio/web-components-sdk/src/types/sdkExceptions/BaseSdkException';

onError: (error: BaseSdkException) => {
  // Check SDK error codes
  if (error.code === 'SDK1114') {
    alert('Card verification failed.');
  } else if (error.code === 'SDK1116') {
    // Card payment failed - check message for specifics
    if (error.message.toLowerCase().includes('declined')) {
      alert('Card declined. Try a different card.');
    } else if (error.message.toLowerCase().includes('insufficient funds')) {
      alert('Insufficient funds.');
    } else if (error.message.toLowerCase().includes('expired')) {
      alert('Card expired.');
    } else if (error.message.toLowerCase().includes('cvv') || 
               error.message.toLowerCase().includes('cvc') ||
               error.message.toLowerCase().includes('security code')) {
      alert('Invalid security code.');
    } else if (error.message.toLowerCase().includes('card number')) {
      alert('Invalid card number.');
    } else if (error.message.toLowerCase().includes('expiry') ||
               error.message.toLowerCase().includes('expiration')) {
      alert('Invalid expiry date.');
    } else {
      alert('Card payment failed. Please check your details.');
    }
  } else if (error.code === 'SDK0500') {
    alert('Connection error. Check your internet.');
  } else if (error.message.toLowerCase().includes('session') || 
             error.message.toLowerCase().includes('expired')) {
    alert('Session expired. Refresh the page.');
  } else if (error.message.toLowerCase().includes('timeout')) {
    alert('Verification timed out.');
  } else {
    alert(`Payment failed: ${error.message}`);
  }
}

Backend verification

Always verify card payments on your backend to ensure payment success before fulfilling orders:

import { BaseSubmitResult } from '@pxpio/web-components-sdk/src/checkoutDropIn/types/BaseSubmitResult';

// FRONTEND: Send to backend
onSuccess: async (result: BaseSubmitResult) => {
  const verified = await fetch('/api/verify-payment', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      systemTransactionId: result.systemTransactionId,
      merchantTransactionId: result.merchantTransactionId
    })
  }).then(r => r.json());
  
  if (verified.success) {
    window.location.href = `/success?orderId=${verified.orderId}`;
  } else {
    alert('Payment verification failed');
  }
}

Backend verification code

Use the following backend code to verify card transactions via the PXP API:

// BACKEND: Complete verification
app.post('/api/verify-payment', async (req, res) => {
  const { systemTransactionId, merchantTransactionId } = req.body;
  
  // 1. Verify transaction
  const txnPath = `api/v1/transactions/${systemTransactionId}`;
  const { authHeader: txnAuthHeader, requestId: txnRequestId } = createAuthHeader(
    txnPath,
    '',
    process.env.PXP_TOKEN_ID,
    process.env.PXP_TOKEN_VALUE
  );
  
  const transaction = await fetch(
    `https://api-services.pxp.io/${txnPath}`,
    {
      headers: {
        'X-Client-Id': process.env.PXP_CLIENT_ID,
        'X-Request-Id': txnRequestId,
        'Authorization': txnAuthHeader
      }
    }
  ).then(r => r.json());
  
  if (transaction.state !== 'Authorised' && transaction.state !== 'Captured') {
    return res.json({ success: false, error: 'Transaction not authorised' });
  }
  
  // Verify amount matches expected amount from your order records
  const order = await getOrderByMerchantTransactionId(merchantTransactionId);
  const txnAmount = transaction.amounts?.transactionValue || transaction.amount || 0;
  if (Math.abs(txnAmount - order.amount) > 0.01) {
    return res.json({ success: false, error: 'Amount mismatch' });
  }
  
  // 2. Verify 3DS if used
  if (transaction.authentication?.authenticationId) {
    const authPath = `api/v1/threedsecure/integrated/authentications/${transaction.authentication.authenticationId}`;
    const { authHeader: auth3dsHeader, requestId: auth3dsRequestId } = createAuthHeader(
      authPath,
      '',
      process.env.PXP_TOKEN_ID,
      process.env.PXP_TOKEN_VALUE
    );
    
    const auth = await fetch(
      `https://api-services.pxp.io/${authPath}`,
      {
        headers: {
          'X-Client-Id': process.env.PXP_CLIENT_ID,
          'X-Request-Id': auth3dsRequestId,
          'Authorization': auth3dsHeader
        }
      }
    ).then(r => r.json());
    
    if (auth.transactionStatus !== 'Y' && auth.transactionStatus !== 'A') {
      return res.json({ success: false, error: '3DS verification failed' });
    }
  }
  
  // 3. Check idempotency
  if (await checkOrderFulfilled(merchantTransactionId)) {
    return res.json({ success: false, error: 'Already processed' });
  }
  
  // 4. Fulfill order
  const orderId = await fulfillOrder(merchantTransactionId, transaction);
  return res.json({ success: true, orderId });
});

Verification checklist

Your backend should verify that:

  • The transaction exists and its state is Authorised or Captured.
  • The amount and currency match the expected values .
  • The transaction is 3DS authenticated (if authenticationId present in transaction response).
  • CVV/AVS were checked (for non-3DS).
  • The transaction wasn't previously processed .
  • There are no replay attacks (authenticationId not reused).

Advanced card flows

3D Secure authentication

Drop-in automatically handles 3DS authentication when required. The payment flow works as follows:

  1. The customer enters their card details and clicks submit.
  2. The card is tokenised securely.
  3. The system evaluates whether 3DS is required:
    • If 3DS is needed: Device fingerprinting → Authentication (frictionless or challenge).
    • If 3DS isn't needed: Direct authorisation with CVV/AVS checks.
  4. The transaction is processed
  5. Your onSuccess callback fires.

Drop-in automatically uses 3DS authentication when:

  • The amount exceeds the risk thresholds configured in the Unity Portal.
  • Strong Customer Authentication (SCA) is required.
  • High-risk indicators are present.
  • The card issuer requires it.

To check if 3DS was used, query the transaction from your backend using the PXP API. The response includes 3DS authentication data when used.

Controlling 3DS behaviour

You can't directly control whether Drop-in uses 3DS in your code. Configure rules in the Unity Portal under Merchant setup > Merchant groups > Services > Card service.

Recurring payments

Drop-in supports recurring payments for subscription-based services and merchant-initiated transactions (MITs). Configure the recurring object in your transaction data to set up the initial payment, then use the stored gatewayTokenId for subsequent charges.

PXP doesn't provide an automatic payment scheduler. You must implement your own scheduling system to initiate subsequent recurring charges via the Transactions API.

How recurring payments work

The recurring payment flow involves two phases:

Phase 1: Initial setup (via Drop-in)

  1. Configure the recurring object in transactionData with frequency and expiration.
  2. The customer enters their card details and completes payment.
  3. Drop-in automatically sets processingModel to MerchantInitiatedInitialRecurring.
  4. The backend stores the gatewayTokenId from the transaction response.

When you include the recurring object in your transaction data, Drop-in automatically sets the processingModel to MerchantInitiatedInitialRecurring for the initial transaction.

Phase 2: Subsequent charges (via backend API)

  1. Your scheduling system triggers a charge based on the frequency.
  2. Your backend calls the Transactions API using the stored gatewayTokenId.
  3. Set processingModel to MerchantInitiatedSubsequentRecurring for subsequent charges.
  4. The payment processes without customer interaction.

Initial recurring payment setup

Configure recurring payment frequency in your Drop-in initialisation:

import CheckoutDropIn from '@pxpio/web-components-sdk/src/checkoutDropIn/CheckoutDropIn';
import IntentType from '@pxpio/web-components-sdk/src/basePxpCheckout/types/IntentType';
import { BaseSubmitResult } from '@pxpio/web-components-sdk/src/checkoutDropIn/types/BaseSubmitResult';
import BaseSdkException from '@pxpio/web-components-sdk/src/types/sdkExceptions/BaseSdkException';

// Get session from backend
const response = await fetch('/api/create-session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
});

const result = await response.json();

if (!result.success || !result.data) {
  console.error('Failed to create session:', result.error);
  throw new Error('Session creation failed');
}

const sessionData = result.data;

// Initialize Drop-in with recurring configuration
const checkoutDropIn = CheckoutDropIn.initialize({
  environment: 'test',
  session: sessionData,
  ownerId: 'MERCHANT-1',
  ownerType: 'MerchantGroup',
  transactionData: {
    currency: 'USD',
    amount: 9.99,
    entryType: 'Ecom',
    intent: { card: IntentType.Authorisation },
    merchantTransactionId: crypto.randomUUID(),
    merchantTransactionDate: () => new Date().toISOString(),
    // Recurring payment configuration
    recurring: {
      frequencyInDays: 30,           // Bill every 30 days
      frequencyExpiration: '2025-12-31' // Token expires on this date
    }
  },
  onGetShopper: () => Promise.resolve({ id: 'customer-123' }),
  onSuccess: async (result: BaseSubmitResult) => {
    console.log('Initial recurring payment setup completed');
    
    // Verify payment and store gatewayTokenId on backend
    const response = await fetch('/api/verify-and-setup-recurring', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        systemTransactionId: result.systemTransactionId,
        merchantTransactionId: result.merchantTransactionId
      })
    }).then(r => r.json());
    
    if (response.success) {
      window.location.href = `/subscription-success?subscriptionId=${response.subscriptionId}`;
    }
  },
  onError: (error: BaseSdkException) => {
    console.error('Recurring payment setup failed:', error);
    alert(`Subscription setup failed: ${error.message}`);
  }
});

checkoutDropIn.create('checkout-drop-in-container');

Backend: Store recurring payment token

After the initial payment, extract and store the gatewayTokenId for future charges:

// BACKEND: Verify initial payment and set up recurring subscription
app.post('/api/verify-and-setup-recurring', async (req, res) => {
  const { systemTransactionId, merchantTransactionId } = req.body;
  
  try {
    // 1. Query PXP API to get transaction details
    const txnPath = `api/v1/transactions/${systemTransactionId}`;
    const { authHeader, requestId } = createAuthHeader(
      txnPath,
      '',
      process.env.PXP_TOKEN_ID,
      process.env.PXP_TOKEN_VALUE
    );
    
    const transaction = await fetch(
      `https://api-services.pxp.io/${txnPath}`,
      {
        headers: {
          'X-Client-Id': process.env.PXP_CLIENT_ID,
          'X-Request-Id': requestId,
          'Authorization': authHeader
        }
      }
    ).then(r => r.json());
    
    // 2. Verify transaction is successful
    if (transaction.state !== 'Authorised' && transaction.state !== 'Captured') {
      return res.json({ success: false, error: 'Transaction not successful' });
    }
    
    // 3. Store gatewayTokenId for recurring charges
    if (transaction.fundingData?.gatewayTokenId) {
      const subscription = await database.subscriptions.insert({
        shopperId: req.user.shopperId,
        gatewayTokenId: transaction.fundingData.gatewayTokenId,
        amount: transaction.amounts?.transactionValue || 0,
        currency: transaction.amounts?.currencyCode || 'USD',
        frequencyInDays: 30,
        nextChargeDate: calculateNextChargeDate(30),
        status: 'active',
        createdAt: new Date()
      });
      
      return res.json({ success: true, subscriptionId: subscription.id });
    }
    
    return res.json({ success: false, error: 'No gateway token found' });
    
  } catch (error) {
    console.error('Setup error:', error);
    return res.json({ success: false, error: 'Setup failed' });
  }
});

Charging subsequent recurring payments

Use the Transactions API to charge subsequent payments from your backend scheduler:

POST
/v1/transactions
// BACKEND: Charge subsequent recurring payment
async function chargeRecurringPayment(subscription) {
  const requestBody = {
    merchant: "MERCHANT-1",
    site: "SITE-1",
    merchantTransactionId: `recurring-${Date.now()}`,
    merchantTransactionDate: new Date().toISOString(),
    transactionMethod: {
      intent: "Purchase",
      entryType: "Ecom",
      fundingType: "Card"
    },
    fundingData: {
      card: {
        gatewayTokenId: subscription.gatewayTokenId
      }
    },
    amounts: {
      transaction: subscription.amount,
      currencyCode: subscription.currency
    },
    recurring: {
      processingModel: "MerchantInitiatedSubsequentRecurring"
    }
  };
  
  const path = 'api/v1/transactions';
  const { authHeader, requestId } = createAuthHeader(
    path,
    JSON.stringify(requestBody),
    process.env.PXP_TOKEN_ID,
    process.env.PXP_TOKEN_VALUE
  );
  
  const response = await fetch(
    `https://api-services.pxp.io/${path}`,
    {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-Client-Id': process.env.PXP_CLIENT_ID,
        'X-Request-Id': requestId,
        'Authorization': authHeader
      },
      body: JSON.stringify(requestBody)
    }
  );
  
  const result = await response.json();
  
  if (result.state === 'Captured' || result.state === 'Authorised') {
    // Update subscription with next charge date
    await database.subscriptions.update(subscription.id, {
      lastChargeDate: new Date(),
      nextChargeDate: calculateNextChargeDate(subscription.frequencyInDays),
      lastTransactionId: result.systemTransactionId
    });
    
    console.log('Recurring charge successful:', result.systemTransactionId);
    return { success: true, transactionId: result.systemTransactionId };
  } else {
    console.error('Recurring charge failed:', result);
    return { success: false, error: result.errorReason };
  }
}

Learn more about initiating transactions via API

Saved cards (card-on-file)

Card-on-file allows returning customers to pay faster by selecting from their saved cards. The drop-in automatically displays saved cards when you implement the onGetShopper callback.

How it works:

  1. The customer completes initial payment and consents to save their card.
  2. The backend stores the gatewayTokenId from the transaction.
  3. On a return visit, onGetShopper provides the shopper ID.
  4. Drop-in displays saved cards for selection.
  5. The customer selects a saved card and pays.

To enable saved cards display, implement the required onGetShopper callback:

import CheckoutDropIn from '@pxpio/web-components-sdk/src/checkoutDropIn/CheckoutDropIn';
import IntentType from '@pxpio/web-components-sdk/src/basePxpCheckout/types/IntentType';
import { BaseSubmitResult } from '@pxpio/web-components-sdk/src/checkoutDropIn/types/BaseSubmitResult';
import BaseSdkException from '@pxpio/web-components-sdk/src/types/sdkExceptions/BaseSdkException';

// Get session data from backend
const response = await fetch('/api/create-session', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' }
});

const result = await response.json();

if (!result.success || !result.data) {
  console.error('Failed to create session:', result.error);
  throw new Error('Session creation failed');
}

const sessionData = result.data;

const checkoutDropIn = CheckoutDropIn.initialize({
  environment: 'test',
  session: sessionData,
  ownerId: 'MERCHANT-1',
  ownerType: 'MerchantGroup',
  transactionData: {
    currency: 'USD',
    amount: 99.99,
    entryType: 'Ecom',
    intent: { card: IntentType.Purchase },
    merchantTransactionId: crypto.randomUUID(),
    merchantTransactionDate: () => new Date().toISOString()
  },
  // REQUIRED: Provide shopper ID to enable card-on-file
  onGetShopper: async () => {
    // Get authenticated user's shopper ID from your session/auth system
    const user = await getCurrentUser();
    return { id: user.shopperId }; // e.g., { id: 'shopper-123' }
  },
  onSuccess: async (result: BaseSubmitResult) => {
    await verifyPaymentOnBackend(result);
    window.location.href = '/success';
  },
  onError: (error: BaseSdkException) => {
    console.error('Payment failed:', error);
    alert(`Payment failed: ${error.message}`);
  }
});

checkoutDropIn.create('checkout-drop-in-container');

When onGetShopper returns a shopper ID, the drop-in automatically:

  • Fetches saved cards from the PXP API.
  • Displays them in a "Saved Cards" section above the new card form.
  • Allows customers to select and pay with saved cards.

Save cards during checkout

Get customer consent before saving cards:

import PaymentMethod from '@pxpio/web-components-sdk/src/components/checkoutDropInComponents/types/PaymentMethod';

const checkoutDropIn = CheckoutDropIn.initialize({
  // ... other config
  onBeforeSubmit: async (paymentMethod: PaymentMethod) => {
    // Only prompt for new card payments
    if (paymentMethod === PaymentMethod.Card) {
      const saveCard = confirm('Save this card for faster checkout next time?');
      if (saveCard) {
        // Store consent in your database
        await storeCardConsent(user.id, true);
      }
    }
    return true; // Proceed with payment
  },
  onSuccess: async (result: BaseSubmitResult) => {
    // Verify payment and extract gatewayTokenId on backend
    const response = await fetch('/api/verify-and-save-card', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({
        systemTransactionId: result.systemTransactionId,
        merchantTransactionId: result.merchantTransactionId,
        saveCard: true // Pass consent to backend
      })
    }).then(r => r.json());
    
    if (response.success) {
      window.location.href = `/success?orderId=${response.orderId}`;
    }
  }
});

Backend: Extract and store gatewayTokenId

After a successful payment, extract the gatewayTokenId from the transaction and store it:

// BACKEND: Verify payment and save card token
app.post('/api/verify-and-save-card', async (req, res) => {
  const { systemTransactionId, merchantTransactionId, saveCard } = req.body;
  
  try {
    // 1. Query PXP API to get transaction details
    const txnPath = `api/v1/transactions/${systemTransactionId}`;
    const { authHeader, requestId } = createAuthHeader(
      txnPath,
      '',
      process.env.PXP_TOKEN_ID,
      process.env.PXP_TOKEN_VALUE
    );
    
    const transaction = await fetch(
      `https://api-services.pxp.io/${txnPath}`,
      {
        headers: {
          'X-Client-Id': process.env.PXP_CLIENT_ID,
          'X-Request-Id': requestId,
          'Authorization': authHeader
        }
      }
    ).then(r => r.json());
    
    // 2. Verify transaction is successful
    if (transaction.state !== 'Authorised' && transaction.state !== 'Captured') {
      return res.json({ success: false, error: 'Transaction not successful' });
    }
    
    // 3. Save gatewayTokenId if customer consented
    if (saveCard && transaction.fundingData?.gatewayTokenId) {
      await database.savedCards.insert({
        shopperId: req.user.shopperId,
        gatewayTokenId: transaction.fundingData.gatewayTokenId,
        last4: transaction.fundingData.cardDetails?.last4Digits,
        cardScheme: transaction.fundingData.cardScheme,
        expiryMonth: transaction.fundingData.cardDetails?.expiryMonth,
        expiryYear: transaction.fundingData.cardDetails?.expiryYear,
        createdAt: new Date()
      });
    }
    
    // 4. Fulfill order
    const orderId = await fulfillOrder(transaction);
    
    return res.json({ success: true, orderId });
    
  } catch (error) {
    console.error('Verification error:', error);
    return res.json({ success: false, error: 'Verification failed' });
  }
});