Skip to content

Recurring payments

Set up recurring payments using card components on iOS.

Overview

Card components support two types of recurring payments:

  • Merchant-initiated transactions (MITs): For subscription-based payments where the customer signs up and pays initially, and you then automatically charge them at a specified interval using your own scheduling system.
  • Card-on-file transactions with shopper consent: For storing payment methods with customer consent for future use, allowing faster checkout for returning customers.

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

Merchant-initiated recurring payments

Merchant-initiated recurring payments require two steps: an initial setup transaction using the SDK, followed by subsequent charges initiated from your backend using the Transactions API.

Step 1: Set up the initial recurring payment

Use the card components to collect the customer's card details and set up the recurring payment. Include the recurring object in your transaction data to specify the payment frequency.

When you include the recurring object in your transaction data, the SDK automatically sets the processingModel to MerchantInitiatedInitialRecurring when creating the transaction request.

import PXPCheckoutSDK

// Configure initial subscription transaction
let expirationFormatter = ISO8601DateFormatter()
expirationFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]

let transactionData = TransactionData(
    amount: Decimal(string: "9.99")!,
    currency: "USD",
    entryType: .ecom,
    intent: TransactionIntentData(card: .authorisation),
    merchantTransactionId: "sub-setup-123",
    merchantTransactionDate: { Date() },
    recurring: RecurringType(
        frequencyInDays: 30,
        frequencyExpiration: expirationFormatter.string(
            from: Calendar.current.date(byAdding: .year, value: 1, to: Date()) ?? Date()
        )
    )
)

// Create SDK configuration
let checkoutConfig = CheckoutConfig(
    environment: .test,
    session: sessionData,
    transactionData: transactionData,
    merchantShopperId: "customer-123",
    ownerType: "MerchantGroup",
    ownerId: "your-owner-id",
    onGetShopper: { async in
        TransactionShopper(
            email: "customer@example.com",
            firstName: "John",
            lastName: "Doe"
        )
    }
)

let pxpCheckout = try PxpCheckout.initialize(config: checkoutConfig)

// Create card components
let cardNumberConfig = CardNumberComponentConfig(label: "Card number")
let cardNumberComponent = try pxpCheckout.create(.cardNumber, componentConfig: cardNumberConfig) as! CardNumberComponent

let expiryConfig = CardExpiryDateComponentConfig(label: "Expiry date")
let expiryComponent = try pxpCheckout.create(.cardExpiryDate, componentConfig: expiryConfig) as! CardExpiryDateComponent

let cvcConfig = CardCvcComponentConfig(label: "CVC")
let cvcComponent = try pxpCheckout.create(.cardCvc, componentConfig: cvcConfig) as! CardCvcComponent

// Create card submit component
var submitConfig = CardSubmitComponentConfig()
submitConfig.cardNumberComponent = cardNumberComponent
submitConfig.cardExpiryDateComponent = expiryComponent
submitConfig.cardCvcComponent = cvcComponent
submitConfig.useCardOnFile = false
submitConfig.submitText = "Start subscription"

submitConfig.onPreAuthorisation = { _ async in
    return TransactionInitiationData()
}

submitConfig.onPostAuthorisation = { result in
    // Check result type - MerchantSubmitResult contains transaction details
    if let merchantResult = result as? MerchantSubmitResult {
        print("Subscription setup completed")
        print("System transaction ID: \(merchantResult.systemTransactionId)")
        
        // Store the system transaction ID for subsequent charges
        Task {
            await saveSubscription(
                systemTransactionId: merchantResult.systemTransactionId,
                customerId: "customer-123",
                amount: Decimal(string: "9.99")!,
                currency: "USD",
                frequency: 30,
                nextChargeDate: calculateNextChargeDate(days: 30)
            )
        }
    } else if let failed = result as? FailedSubmitResult {
        print("Transaction failed: \(failed.errorReason ?? "Unknown error")")
    }
}

let submitComponent = try pxpCheckout.create(.cardSubmit, componentConfig: submitConfig) as! CardSubmitComponent

Step 2: Charge subsequent recurring payments

For subsequent recurring charges, you must initiate transactions from your backend using the Transactions API. PXP doesn't automatically charge customers based on the frequencyInDays, so you need to implement your own scheduler to trigger these charges.

POST
/v1/transactions

Use the following request to charge a customer for a subsequent recurring payment. Replace the full card details with the gatewayTokenId you received from the initial transaction.

{
  "merchant": "MERCHANT-1",
  "site": "SITE-1",
  "merchantTransactionId": "sub-charge-456",
  "merchantTransactionDate": "2025-02-27T08:51:02.826Z",
  "transactionMethod": {
    "intent": "Purchase",
    "entryType": "Ecom",
    "fundingType": "Card"
  },
  "fundingData": {
    "card": {
      "gatewayTokenId": "5fbd77ce-02c1-40ed-94bc-1016660b7512"
    }
  },
  "amounts": {
    "transaction": 9.99,
    "currencyCode": "USD"
  },
  "recurring": {
    "processingModel": "MerchantInitiatedSubsequentRecurring"
  }
}
ParameterDescription
merchant
string (≤ 20 characters)
required
Your unique merchant identifier, as assigned by PXP.
site
string (≤ 20 characters)
required
Your unique site identifier, as assigned by PXP.
merchantTransactionId
string (≤ 50 characters)
required
A unique identifier for this transaction.
merchantTransactionDate
date-time
required
The date and time when the transaction happened, in ISO 8601 format.
transactionMethod
object
required
Details about the transaction method.
transactionMethod.intent
string (enum)
required
The payment intent. For recurring charges, use Purchase or Authorisation.

Possible values:
  • Authorisation
  • Purchase
transactionMethod.entryType
string
required
The entry type. For e-commerce transactions, this is always Ecom.
transactionMethod.fundingType
string
required
The funding type. For card transactions, this is always Card.
fundingData
object
required
Details about the payment method used for the transaction.
fundingData.card
object
required
Details about the card.
fundingData.card.gatewayTokenId
string (UUID)
required
The gateway token ID from the initial transaction. This replaces the need to provide full card details.
amounts
object
required
Details about the transaction amount.
amounts.transaction
number
required
The transaction amount.
amounts.currencyCode
string (3 characters)
required
The currency code in ISO 4217 format.
recurring
object
required
Details about the recurring payment.
recurring.processingModel
string (enum)
required
The processing model for the recurring payment. Use MerchantInitiatedSubsequentRecurring for standard subscription charges.

Possible values:
  • MerchantInitiatedSubsequentRecurring
  • MerchantInitiatedReAuthorisation
  • MerchantInitiatedResubmission
  • MerchantInitiatedDelayedCharge
  • MerchantInitiatedNoShow

Response example

If your request is successful, you'll receive a 200 response with the transaction state. You'll also receive a webhook notification.

{
  "state": "Captured",
  "stateData": {},
  "approvalCode": "210693",
  "merchantTransactionId": "sub-charge-456",
  "systemTransactionId": "635b1b51-4a27-4d23-8c9f-8150ff7eb9dd",
  "merchantTransactionDate": "2025-02-27T08:51:02.826Z",
  "fundingData": {
    "cardScheme": "Visa",
    "maskedPrimaryAccountNumber": "411111******1111",
    "expiryMonth": "09",
    "expiryYear": "2025",
    "gatewayTokenId": "5fbd77ce-02c1-40ed-94bc-1016660b7512",
    "providerResponse": {
      "provider": "pxpfinancial",
      "code": "00",
      "paymentAccountReference": "637607302178175469",
      "authorisedAmount": 9.99
    }
  }
}

Learn more about initiating transactions via API

Card-on-file recurring payments

Step 1: Set up the initial transaction

To save a card, you'll first need to ask for the customer's consent during the initial transaction. If the customer ticks the checkbox, their card details will be saved as a token for future reuse.

import PXPCheckoutSDK

let transactionData = TransactionData(
    amount: Decimal(string: "99.99")!,
    currency: "USD",
    entryType: .ecom,
    intent: TransactionIntentData(card: .authorisation),
    merchantTransactionId: "order-12345",
    merchantTransactionDate: { Date() }
)

let checkoutConfig = CheckoutConfig(
    environment: .test,
    session: sessionData,
    transactionData: transactionData,
    merchantShopperId: "customer-123",
    ownerType: "MerchantGroup",
    ownerId: "your-owner-id",
    onGetShopper: { async in
        TransactionShopper(
            email: "customer@example.com",
            firstName: "John",
            lastName: "Doe"
        )
    }
)

let pxpCheckout = try PxpCheckout.initialize(config: checkoutConfig)

// Create the card consent component
let consentConfig = CardConsentComponentConfig(
    label: "Save this card for faster checkout in the future"
)
let consentComponent = try pxpCheckout.create(.cardConsent, componentConfig: consentConfig) as! CardConsentComponent

// Create the card input components
let cardNumberComponent = try pxpCheckout.create(.cardNumber, componentConfig: CardNumberComponentConfig(label: "Card number")) as! CardNumberComponent

let expiryComponent = try pxpCheckout.create(.cardExpiryDate, componentConfig: CardExpiryDateComponentConfig(label: "Expiry date")) as! CardExpiryDateComponent

let cvcComponent = try pxpCheckout.create(.cardCvc, componentConfig: CardCvcComponentConfig(label: "CVC")) as! CardCvcComponent

// Create the card submit component
var submitConfig = CardSubmitComponentConfig()
submitConfig.cardNumberComponent = cardNumberComponent
submitConfig.cardExpiryDateComponent = expiryComponent
submitConfig.cardCvcComponent = cvcComponent
submitConfig.cardConsentComponent = consentComponent
submitConfig.useCardOnFile = false
submitConfig.submitText = "Pay & save card"

submitConfig.onPostTokenisation = { result in
    if let success = result as? CardTokenizationResultSuccess {
        print("Card tokenised. Gateway Token ID: \(success.gatewayTokenId)")
        
        // Get full token details from backend if needed for validation
        Task {
            let tokenDetails = await getTokenDetailsFromBackend(success.gatewayTokenId)
            print("Card saved for future use")
            
            // Store the token reference in your system
        }
    } else if let failed = result as? CardTokenizationResultFailed {
        print("Tokenisation failed: \(failed.errorReason)")
    }
}

submitConfig.onPreAuthorisation = { _ async in
    return TransactionInitiationData()
}

submitConfig.onPostAuthorisation = { result in
    // Check result type - MerchantSubmitResult contains transaction details
    if let merchantResult = result as? MerchantSubmitResult {
        print("Payment successful, card saved for future use")
        print("System transaction ID: \(merchantResult.systemTransactionId)")
    } else if let failed = result as? FailedSubmitResult {
        print("Transaction failed: \(failed.errorReason ?? "Unknown error")")
    }
}

let submitComponent = try pxpCheckout.create(.cardSubmit, componentConfig: submitConfig) as! CardSubmitComponent

Step 2: Set up subsequent transactions

For subsequent transactions with saved cards, you can choose from:

  • A traditional card selection: This is best for customers with multiple saved cards and provides full card management (edit, delete, update) but requires more interaction from the customer. It uses the card-on-file component.
  • A streamlined click-once checkout: This is ideal for customers with one primary payment method and offers a faster checkout experience, but less flexibility. It uses the standalone click-once component.
  • A hybrid approach: This combines the two components. It defaults to displaying a primary card, but allows customers to choose a different one if they want to.

When using card-on-file or click-once components, the SDK automatically sets the processingModel to CardOnFileShopperInitiated for subsequent transactions.

var cofConfig = CardOnFileComponentConfig()
cofConfig.limitTokens = 10
cofConfig.orderBy = OrderByConfig(
    lastUsageDate: OrderByLastUsageDateConfig(
        orderByField: "lastSuccessfulPurchaseDate",
        direction: "desc",
        priority: 1
    )
)
cofConfig.deleteCardButtonAccessibilityLabel = "Delete this card"
cofConfig.editCardInformationAccessibilityLabel = "Edit card details"

let cardOnFileComponent = try pxpCheckout.create(
    .cardOnFile,
    componentConfig: cofConfig
) as! CardOnFileComponent

Using the API for backend-initiated card-on-file transactions

While the components above handle shopper-initiated transactions (where the customer is present and actively making a payment), you can also use the Transactions API to charge a saved card from your backend without the customer being present.

This is useful for scenarios like:

  • Auto-renewing a service when the customer returns to your platform.
  • Charging for usage-based billing at the end of a billing period.
  • Processing payments for bookings or reservations.

To charge a saved card via API, use the CardOnFileShopperInitiated processing model with the gatewayTokenId:

{
  "merchant": "MERCHANT-1",
  "site": "SITE-1",
  "merchantTransactionId": "charge-001",
  "merchantTransactionDate": "2025-02-27T08:51:02.826Z",
  "transactionMethod": {
    "intent": "Purchase",
    "entryType": "Ecom",
    "fundingType": "Card"
  },
  "fundingData": {
    "card": {
      "gatewayTokenId": "5fbd77ce-02c1-40ed-94bc-1016660b7512"
    }
  },
  "amounts": {
    "transaction": 49.99,
    "currencyCode": "USD"
  },
  "recurring": {
    "processingModel": "CardOnFileShopperInitiated"
  }
}

Learn more about card-on-file payments via API.

3DS authentication for recurring payments

For the initial customer-present setup transaction, you should use integrated 3DS authentication. Configure both onPreInitiateAuthentication and onPreAuthentication callbacks. Use RequestorAuthenticationIndicatorType.recurringTransaction in PreInitiateIntegratedAuthenticationData, and include ThreeDSRecurring on InitiateIntegratedAuthenticationData so the issuer understands the recurring payment frequency and agreement expiration.

var submitConfig = CardSubmitComponentConfig()

submitConfig.onPreInitiateAuthentication = {
    return PreInitiateIntegratedAuthenticationData(
        providerId: "your_3ds_provider_id",
        requestorAuthenticationIndicator: .recurringTransaction,
        timeout: 120
    )
}

submitConfig.onPreAuthentication = {
    return InitiateIntegratedAuthenticationData(
        merchantCountryNumericCode: "826",
        merchantLegalName: "Your Company Ltd",
        challengeWindowSize: .size5,
        requestorChallengeIndicator: .noPreference,
        recurring: ThreeDSRecurring(
            expirationDate: Calendar.current.date(byAdding: .year, value: 1, to: Date()),
            frequencyInDays: 30
        ),
        timeout: 300
    )
}

For subsequent merchant-initiated charges, you typically don't need 3DS authentication. Leave both 3DS callbacks unset, or return nil from onPreInitiateAuthentication if both callbacks are configured. For card-on-file MIT flows, set transactionInitiatorType = .MIT on CardOnFileComponentConfig to indicate the transaction was merchant-initiated.

Learn more about 3DS transactions

Errors and retries

  • onSubmitError: Returns BaseSdkException with errorCode and errorMessage for token vault, validation, network, and similar errors.
  • onPostAuthorisation: Returns AuthorisedSubmitResult, CapturedSubmitResult, RefusedSubmitResult, or FailedSubmitResult. Treat a normal issuer decline as RefusedSubmitResult.
submitConfig.onSubmitError = { error in
    print("Error code: \(error.errorCode)")
    print("Error message: \(error.errorMessage)")
}

submitConfig.onPostAuthorisation = { result in
    switch result {
    case is AuthorisedSubmitResult, is CapturedSubmitResult:
        print("Transaction successful")
    case let refused as RefusedSubmitResult:
        print("Transaction refused: \(refused.stateData?.message ?? "")")
    case let failed as FailedSubmitResult:
        print("Transaction failed: \(failed.errorReason ?? "Unknown error")")
    default:
        print("Unknown result type")
    }
}

Use a new merchantTransactionId (and refreshed TransactionData / CheckoutConfig) for each retry attempt. For soft-decline handling, use the onPreRetrySoftDecline callback on CardSubmitComponentConfig.

Learn more about card submit configuration.