Skip to main content

Adyen iOS native (Apple Pay)

This guide covers integrating Apple Pay checkout in a native iOS app using the Adyen iOS SDK and the Centra Storefront API. It assumes you are familiar with the express checkout flow overview and the web express checkout guide.

The Centra Storefront API is fully native-compatible — all API calls are GraphQL over HTTPS with token-based auth. The expressCheckoutWidgets query returns raw JSON config that you pass directly to the Adyen iOS SDK.

Prerequisites

Centra

  • Adyen Drop-in configured as a payment method in your Centra store
  • Apple Pay enabled in the Adyen Drop-in plugin settings

iOS project

  • iOS 13.0+, Xcode 15.0+, Swift 5.7+
  • An Apple Pay Merchant ID registered in the Apple Developer portal
  • The Apple Pay capability added in your Xcode project
  • The merchant certificate uploaded to your Adyen dashboard

SDK installation

Swift Package Manager (recommended):

https://github.com/Adyen/adyen-ios

Add the AdyenComponents and AdyenActions modules. Or with CocoaPods:

pod 'Adyen'

Session management

Centra uses token-based sessions. Every Storefront API response includes an extensions.token field — persist this in the Keychain and send it as Authorization: Bearer {token} on every request. See Authorization & sessions for details.

Integration steps

1. Fetch express checkout configuration

Request expressCheckoutWidgets from your backend. This call requires the X-Shared-Secret header, so it must go through a server-side proxy — never expose the shared secret in the iOS app.

Your backend calls:

query expressCheckoutWidgets($input: ExpressCheckoutWidgetsInput!) {
expressCheckoutWidgets(input: $input) {
type
... on AdyenExpressCheckoutWidget {
clientKey
paymentMethodsResponse
country
context
languageCode
paymentAmount {
amount
currency
}
}
}
}

Parse the response into a Swift model:

struct AdyenExpressConfig: Codable {
let clientKey: String
let paymentMethodsResponse: AnyCodable
let country: String
let context: String
let languageCode: String
let paymentAmount: PaymentAmount

struct PaymentAmount: Codable {
let amount: Int
let currency: String
}
}

Fetch from your backend endpoint (not directly from Centra — your backend holds the shared secret):

func fetchExpressConfig() async throws -> AdyenExpressConfig {
var request = URLRequest(url: URL(string: "https://your-backend.com/api/express-config")!)
request.setValue("Bearer \(centraToken)", forHTTPHeaderField: "Authorization")

let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode(AdyenExpressConfig.self, from: data)
}

2. Initialize Adyen context

Use the Advanced flow — Centra manages the server-side payment session, not Adyen Sessions.

import Adyen

let environment: Adyen.Environment = config.context == "live"
? .liveEurope // or .liveAustralia, .liveUS depending on region
: .test

let amount = Amount(
value: config.paymentAmount.amount,
currencyCode: config.paymentAmount.currency
)

let apiContext = try APIContext(
environment: environment,
clientKey: config.clientKey
)

let adyenContext = AdyenContext(
apiContext: apiContext,
payment: Payment(amount: amount, countryCode: config.country)
)

Parse the paymentMethodsResponse from the config into Adyen's type:

let paymentMethodsData = try JSONSerialization.data(
withJSONObject: config.paymentMethodsResponse.value
)
let paymentMethods = try JSONDecoder().decode(
PaymentMethods.self,
from: paymentMethodsData
)

3. Set up Apple Pay component

Create an ApplePayComponent configured for express checkout:

import AdyenComponents
import PassKit

var applePayConfig = ApplePayComponent.Configuration(
summaryItems: [
PKPaymentSummaryItem(
label: "Your Store",
amount: NSDecimalNumber(
value: Double(config.paymentAmount.amount) / 100.0
)
)
],
merchantIdentifier: "merchant.com.yourapp"
)

applePayConfig.requiredShippingContactFields = [
.postalAddress, .name, .emailAddress, .phoneNumber
]
applePayConfig.requiredBillingContactFields = [
.postalAddress, .name
]
applePayConfig.supportedNetworks = [.visa, .masterCard, .amex]

let applePayComponent = try ApplePayComponent(
paymentMethod: paymentMethods.paymentMethod(ofType: ApplePayPaymentMethod.self)!,
context: adyenContext,
configuration: applePayConfig
)

applePayComponent.isExpress = true
applePayComponent.delegate = self

Check availability before showing the button:

guard ApplePayComponent.isAvailable(
paymentMethod: applePayPaymentMethod,
configuration: applePayConfig
) else {
// Apple Pay not available on this device — hide the button
return
}

Present the component:

// UIKit
present(applePayComponent.viewController, animated: true)

// SwiftUI — wrap in UIViewControllerRepresentable

4. Handle Apple Pay callbacks

Implement the ApplePayComponentDelegate and PaymentComponentDelegate protocols. Each callback maps to a Centra API call:

Web (@adyen/adyen-web)iOS (adyen-ios)Centra API call
onClickButton tap (pre-presentation)query selection + optional mutation addItem
onShippingContactSelectedapplePayComponent(_:didUpdateContact:)mutation paymentInstructions(paymentInitiateOnly: true)
onShippingMethodSelectedapplePayComponent(_:didUpdateShippingMethod:)mutation setShippingMethod
onAuthorizedapplePayComponent(_:didAuthorizePayment:)Validate and map addresses locally
onSubmitdidSubmit(_:from:)mutation paymentInstructions (final)

Before presenting (item preparation)

Before showing the Apple Pay sheet, ensure the selection is ready. If this is a PDP express checkout, add the item:

// Before presenting the Apple Pay component
let selection = try await centraClient.query(SelectionQuery())

if let itemToAdd = pendingExpressItem {
let addResult = try await centraClient.mutate(
AddItemMutation(item: itemToAdd.itemId, quantity: 1)
)
addedLineId = addResult.addItem?.selection?.lines?.first?.id
}

Shipping contact changed

When the shopper selects or changes their shipping address in the Apple Pay sheet:

func applePayComponent(
_ component: ApplePayComponent,
didUpdateContact contact: PKContact,
completion: @escaping (PKPaymentRequestShippingContactUpdate) -> Void
) {
Task {
let address = mapPKContactToCentraAddress(contact)

let result = try await centraClient.mutate(
PaymentInstructionsMutation(
input: PaymentInstructionsInput(
paymentMethod: adyenPaymentMethodId,
shippingAddress: address,
paymentInitiateOnly: true,
paymentReturnPage: returnURL,
paymentFailedPage: failedURL,
termsAndConditions: true
)
)
)

let selection = result.paymentInstructions?.selection
let shippingMethods = buildPKShippingMethods(from: selection)
let summaryItems = buildSummaryItems(from: selection)

completion(PKPaymentRequestShippingContactUpdate(
errors: nil,
paymentSummaryItems: summaryItems,
shippingMethods: shippingMethods
))
}
}

Shipping method changed

When the shopper picks a different shipping option in the sheet:

func applePayComponent(
_ component: ApplePayComponent,
didUpdateShippingMethod shippingMethod: PKShippingMethod,
completion: @escaping (PKPaymentRequestShippingMethodUpdate) -> Void
) {
Task {
let result = try await centraClient.mutate(
SetShippingMethodMutation(id: shippingMethod.identifier!)
)

let selection = result.setShippingMethod?.selection
let summaryItems = buildSummaryItems(from: selection)

completion(PKPaymentRequestShippingMethodUpdate(
paymentSummaryItems: summaryItems
))
}
}

Payment authorized

After the shopper authenticates with Face ID / Touch ID, map the Apple Pay addresses to Centra's format:

func applePayComponent(
_ component: ApplePayComponent,
didAuthorizePayment payment: PKPayment,
completion: @escaping (PKPaymentAuthorizationResult) -> Void
) {
let shippingContact = payment.shippingContact
let billingContact = payment.billingContact

currentShippingAddress = mapPKContactToCentraAddress(shippingContact)
currentBillingAddress = mapPKContactToCentraAddress(billingContact)

completion(PKPaymentAuthorizationResult(status: .success, errors: nil))
}

Address mapping helper

Map PKContact fields to Centra's address input:

func mapPKContactToCentraAddress(_ contact: PKContact?) -> CentraAddressInput {
let postal = contact?.postalAddress
let name = contact?.name

return CentraAddressInput(
firstName: name?.givenName,
lastName: name?.familyName,
address1: postal?.street,
city: postal?.city,
state: postal?.state,
zipCode: postal?.postalCode,
country: postal?.isoCountryCode?.uppercased(),
email: contact?.emailAddress,
phoneNumber: contact?.phoneNumber?.stringValue
)
}

Final payment submission (didSubmit)

When Adyen submits the final payment data:

func didSubmit(
_ data: PaymentComponentData,
from component: PaymentComponent,
dropInComponent: AnyDropInComponent?
) {
Task {
let paymentFields = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(data.paymentMethod)
)

let result = try await centraClient.mutate(
PaymentInstructionsMutation(
input: PaymentInstructionsInput(
paymentMethod: adyenPaymentMethodId,
shippingAddress: currentShippingAddress,
separateBillingAddress: currentBillingAddress,
paymentMethodSpecificFields: paymentFields,
paymentReturnPage: returnURL,
paymentFailedPage: failedURL,
termsAndConditions: true
)
)
)

handlePaymentResponse(result, component: component)
}
}

5. Handle payment response and 3D Secure

After paymentInstructions, inspect the response action:

func handlePaymentResponse(
_ result: PaymentInstructionsPayload,
component: PaymentComponent
) {
guard let action = result.paymentInstructions?.action else {
// No action — check for errors
if let errors = result.paymentInstructions?.userErrors, !errors.isEmpty {
showError(errors)
}
return
}

switch action.__typename {
case "JavascriptPaymentAction":
// 3DS — extract formFields, build Adyen Action, handle natively
if let formFields = action.formFields,
let actionData = try? JSONSerialization.data(withJSONObject: formFields),
let adyenAction = try? JSONDecoder().decode(Action.self, from: actionData) {
component.handle(action: adyenAction)
}

case "RedirectPaymentAction":
// Redirect-based 3DS or payment method
if let urlString = action.url, let url = URL(string: urlString) {
let session = ASWebAuthenticationSession(
url: url,
callbackURLScheme: "yourapp"
) { callbackURL, error in
guard let callbackURL else { return }
self.handleReturnURL(callbackURL)
}
session.presentationContextProvider = self
session.start()
}

case "SuccessPaymentAction":
// Payment completed — navigate to confirmation
navigateToOrderConfirmation()

default:
break
}
}

3DS completion (didProvide)

After the Adyen SDK completes 3DS natively, it calls didProvide. Send the result to Centra:

func didProvide(
_ data: ActionComponentData,
from component: ActionComponent,
dropInComponent: AnyDropInComponent?
) {
Task {
let fields = try JSONSerialization.jsonObject(
with: JSONEncoder().encode(data.details)
) as? [String: Any] ?? [:]

let result = try await centraClient.mutate(
PaymentResultMutation(paymentMethodFields: fields)
)

if result.paymentResult?.type == "success" {
navigateToOrderConfirmation()
} else {
showError(result.paymentResult?.userErrors)
}
}
}

Return URL handling

For redirect-based 3DS, register a URL scheme in Info.plist:

<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>yourapp</string>
</array>
</dict>
</array>

Handle the return in your SceneDelegate:

func scene(_ scene: UIScene, openURLContexts URLContexts: Set<UIOpenURLContext>) {
guard let url = URLContexts.first?.url else { return }

// Parse query parameters and call paymentResult
let params = URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.reduce(into: [String: String]()) { $0[$1.name] = $1.value } ?? [:]

Task {
let result = try await centraClient.mutate(
PaymentResultMutation(paymentMethodFields: params)
)
// Handle result...
}
}

6. Payment completion

On success: query the placed order and navigate to your confirmation screen:

let order = try await centraClient.query(LatestOrderQuery())
navigateToConfirmation(order: order)

On failure: show the error and allow retry. The shopper can tap the Apple Pay button again.

On cancellation: if you added an item for PDP express checkout, roll it back:

if let lineId = addedLineId {
try await centraClient.mutate(
UpdateLineMutation(id: lineId, quantity: 0)
)
addedLineId = nil
}

Operational hints

  • Availability: always check ApplePayComponent.isAvailable() before rendering the button. Silently hide it on unsupported devices.
  • Entitlement checklist: Apple Developer merchant ID, Xcode Apple Pay capability, merchant certificate in Adyen dashboard, Apple Pay enabled in Centra plugin.
  • Debugging: set AdyenLogging.isEnabled = true during development. Monitor userErrors in every Centra response.
  • Token persistence: store the Centra session token in Keychain. Update it from extensions.token on every API response.
  • Simulator testing: the iOS Simulator supports Apple Pay with Adyen sandbox test cards.

Validation checklist

Before going live, verify:

  • Apple Pay button appears on supported devices and is hidden otherwise
  • Payment sheet shows the correct amount and line items
  • Shipping address change updates totals and available shipping methods
  • Shipping method change updates the total
  • Authorization completes and the order is placed in Centra
  • 3DS challenge can be completed (both native and redirect flows)
  • Cancellation removes any temporarily added line items
  • Confirmation screen shows correct order data from query order

Shipping: Ingrid is not supported natively

Centra's Ingrid integration is a hard-coded HTML widget. The IngridWidget returns an HTML snippet that requires a browser DOM and relies on browser globals (window.CentraCheckout, window._sw). There is no API-only mode for Ingrid — it cannot be rendered in a native app without embedding a web view.

For native checkout, shipping works as follows:

  • If Ingrid is NOT enabled: use Centra's standard shipping methods via mutation setShippingMethod. These are fully native-compatible and returned as structured JSON from the selection.

  • If Ingrid IS enabled: it will interfere with the selection during address submission and payment finalization (same problem described in the web express checkout guide's Ingrid section). Apply the same workaround — call mutation handleWidgetEvent with { expressCheckout: true } during initialization, then use standard Centra shipping methods only. The order will be placed outside of Ingrid:

    mutation widgetEvent($payload: Map!) {
    handleWidgetEvent(payload: $payload) {
    selection {
    ...selectionFragment
    checkout {
    ...checkoutFragment
    }
    }
    }
    }

    With variables:

    {
    "payload": {
    "expressCheckout": true
    }
    }

    After initialization, set the first available shipping method from the selection response using mutation setShippingMethod.

  • If Ingrid delivery options are a hard requirement: the Ingrid widget must be rendered in a WKWebView. This is outside the scope of this guide.