Set up recurring payments using card components on iOS.
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 require two steps: an initial setup transaction using the SDK, followed by subsequent charges initiated from your backend using the Transactions API.
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! CardSubmitComponentFor 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.
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"
}
}| Parameter | Description |
|---|---|
merchantstring (≤ 20 characters) required | Your unique merchant identifier, as assigned by PXP. |
sitestring (≤ 20 characters) required | Your unique site identifier, as assigned by PXP. |
merchantTransactionIdstring (≤ 50 characters) required | A unique identifier for this transaction. |
merchantTransactionDatedate-time required | The date and time when the transaction happened, in ISO 8601 format. |
transactionMethodobject required | Details about the transaction method. |
transactionMethod.intentstring (enum) required | The payment intent. For recurring charges, use Purchase or Authorisation.Possible values:
|
transactionMethod.entryTypestring required | The entry type. For e-commerce transactions, this is always Ecom. |
transactionMethod.fundingTypestring required | The funding type. For card transactions, this is always Card. |
fundingDataobject required | Details about the payment method used for the transaction. |
fundingData.cardobject required | Details about the card. |
fundingData.card.gatewayTokenIdstring (UUID) required | The gateway token ID from the initial transaction. This replaces the need to provide full card details. |
amountsobject required | Details about the transaction amount. |
amounts.transactionnumber required | The transaction amount. |
amounts.currencyCodestring (3 characters) required | The currency code in ISO 4217 format. |
recurringobject required | Details about the recurring payment. |
recurring.processingModelstring (enum) required | The processing model for the recurring payment. Use MerchantInitiatedSubsequentRecurring for standard subscription charges.Possible values:
|
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
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! CardSubmitComponentFor 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! CardOnFileComponentWhile 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.
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
onSubmitError: ReturnsBaseSdkExceptionwitherrorCodeanderrorMessagefor token vault, validation, network, and similar errors.onPostAuthorisation: ReturnsAuthorisedSubmitResult,CapturedSubmitResult,RefusedSubmitResult, orFailedSubmitResult. Treat a normal issuer decline asRefusedSubmitResult.
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.