Skip to main content

Wallet Extensions Guide

Wallet Extensions (In-App Provisioning or Issuer Extensions) introduced in iOS 14 makes it easier for users to know that they can add a payment pass to Apple Pay by improving discoverability from within Apple Wallet. In-App provisioning process via Wallet Extensions starts and finishes within Apple Wallet.

Adding Wallet Extensions

MPP React Native library allows to add Wallet Extensions to an existing React Native project.

1. Creating App Extension targets

Open your react native project in Xcode and select Intents Extension as the base. Create two app extensions IssuerNonUIExtension and IssuerUIExtension following the steps below.

  • Add an app extension to Xcode app project, choose File > New > Target, select iOS > Application Extension > Intents Extension.

    Issuer Extensions Target

  • Set options for the new target. Create a unique bundle ID for the extension and add to associatedApplicationIdentifiers in PNO metadata, e.g.:

    com.meawallet.app
    com.meawallet.app.IssuerNonUIExtension
    com.meawallet.app.IssuerUIExtension

    Issuer Extensions Target Options

  • Activate the created scheme, if asked.

  • Add PassKit.framework to Frameworks and Libraries of the IssuerNonUIExtension target, and remove Intents.framework which is not needed.

  • Remove Intents.framework of the IssuerUIExtension target.

    Issuer Extensions Target Frameworks

  • Make sure that in Signing & Capabilities > Deployment info iOS version is set to at least 14.0. Also, if you test device's iOS version is older than version set in Extension's Target Deployment info, your Issuer Extension won't be loaded by the System and it won't be visible in the Apple Wallet.

  • It is safe to remove .swift sources created by Intents Extension template. However, at least one empty "dummy" native source file is needed for IssuerNonUIExtension and IssuerUIExtension target for build system to work.

    Dummy source file

  • Make sure that mea_config configuration file is added to your Extension's Target and included in Extension App Bundle:

    Configuration file added to the Extension's Target

2. Updating Info.plist

  • Remove NSExtensionAttributes entry from NSExtension dictionary in extension's Info.plist file.

  • Specify NSExtensionPointIdentifier and NSExtensionPrincipalClass in the NSExtension dictionary:

    • Issuer Non-UI App Extension

      KeyTypeValue
      NSExtensionPointIdentifierStringcom.apple.PassKit.issuer-provisioning
      NSExtensionPrincipalClassStringmeawallet_react_native_mpp.MppIssuerExtensionHandler

      Issuer Extensions Plist

    • Issuer UI App Extension

      KeyTypeValue
      NSExtensionPointIdentifierStringcom.apple.PassKit.issuer-provisioning.authorization
      NSExtensionPrincipalClassStringmeawallet_react_native_mpp.MppIssuerAuthorizationExtensionHandler

      Issuer Extensions Plist UI

3. Updating Podfile specification

Define IssuerNonUIExtension and IssuerUIExtension nested targets`:

ios/Podfile
target 'MainApp' do

# ...

target 'IssuerNonUIExtension' do
end

target 'IssuerUIExtension' do
end

# ...

end

Run pod install to update the Xcode project.

4. Setting Code Signing Entitlements

Issuer Extensions use the same entitlement file used for issuer app In-App Provisioning functionality. Entitlements

5. Registering App Extension Components

  • Component for Issuer Non-UI App Extension

    index.IssuerNonUIExtension.js
    import { AppRegistry } from 'react-native';
    import IssuerNonUIExtension from './src/extensions/App.IssuerNonUIExtension';

    AppRegistry.registerComponent("IssuerNonUIExtension", () => IssuerNonUIExtension)
  • Component for Issuer UI App Extension

    index.IssuerUIExtension.js
    import { AppRegistry } from 'react-native';
    import IssuerUIExtension from './src/extensions/App.IssuerUIExtension';

    AppRegistry.registerComponent("IssuerUIExtension", () => IssuerUIExtension)

6. Adding React Native Bundle Build Phase

  • Add a new Bundle React Native code and images Build Phase Run Script to both extension targets.

    Add "Run Script" Build Phase

  • Define a custom .js entry file for each Extension by setting ENTRY_FILE environment variable used by react-native-xcode.sh script.

    • Issuer Non-UI App Extension

      export NODE_BINARY=node
      export ENTRY_FILE=index.IssuerNonUIExtension.js
      ../node_modules/react-native/scripts/react-native-xcode.sh

      Bundle React Native code and images

    • Issuer UI App Extension

      export NODE_BINARY=node
      export ENTRY_FILE=index.IssuerUIExtension.js
      ../node_modules/react-native/scripts/react-native-xcode.sh

      Bundle React Native code and images

7. Sharing Data between App and App Extensions

App and App Extensions can access shared App Group data. Use react-native-default-preference React Native package or other library to access the shared App Group data.

Run npm i react-native-default-preference to install the package.

Sample:

import GroupPreference from 'react-native-default-preference';
// Set your App Group name
GroupPreference.setName('group.com.issuer.app')

// Save data in one App / App Extension
GroupPreference.set('data', JSON.stringify(data))

// Read data in another App / App Extension
dataJSON = await GroupPreference.get('session')

References

8. Implementing Issuer Authorization Provider Extension

Apple Wallet uses Issuer UI App Extension to determine the user's authorization status.

The issuer app performs authentication of the user as a part of existing security framework, such as Face ID, Touch ID, or another authentication method subject to issuer requirements. Many issuer apps rely on a biometric authentication like Face ID and Touch ID to provide the most seamless experience for the user.

It is a good practice to use an existing segment of login from the main issuer app for the UI extension. The screen can be identical to the issuer app login and use the same login credentials.

src/extension/App.IssuerUIExtension.tsx
import React from "react";
import { TextInput, Button, SafeAreaView } from "react-native";
import MeaPushProvisioning from "@meawallet/react-native-mpp";
import GroupPreference from 'react-native-default-preference';

// Set your App Group name
GroupPreference.setName('group.com.issuer.app')

async function login(email: string, password: string) {
try {
// Authenticate user and fetch session
const session = await authenticate({ email: email, password:password })
if (session) {
// Store your custom session data for future use by Non-UI App Extension
GroupPreference.set('session', JSON.stringify(session))

// The issuer declares to the Apple Wallet
// that the user successfully authorized adding a payment pass.
MeaPushProvisioning.ApplePay.IssuerUIExtension.completeAuthentication(true)
} else {
throw new Error("Authentication error")
}
} catch(error) {
console.log(`Login error: ${error}`)

// The issuer declares that the user canceled authorization or
// doesn’t have authorization to add the payment pass.
MeaPushProvisioning.ApplePay.IssuerUIExtension.completeAuthentication(false)
}
}

const IssuerUIExtension = () => {
const [email, onChangeEmail] = React.useState('')
const [password, onChangePassword] = React.useState('')

return (
<SafeAreaView style={{flex:1,justifyContent: "center"}}>
<TextInput
style={{borderWidth:1, margin:16, padding:12}}
onChangeText={onChangeEmail}
value={email}
/>
<TextInput
style={{borderWidth:1, margin:16, padding:12}}
onChangeText={onChangePassword}
value={password}
secureTextEntry={true}
/>
<Button
title="Log In"
onPress={ () => login(email, password) }
/>
</SafeAreaView>
)
}

export default IssuerUIExtension

9. Implementing IssuerExtensionHandler

After authorization, Apple Wallet calls Non-UI App Extension to interrogate the issuer app to determine the list of payment passes available to be added to iPhone Wallet and Apple Watch.

Implement following methods of an abstract class IssuerExtensionHandler:

  • status(): Promise<IssuerExtensionStatus> method reports the status of your Wallet Extension.
  • passEntries(): Promise<IssuerExtensionPaymentPassEntry[]> method reports the list of passes available for iPhone Wallet.
  • remotePassEntries(): Promise<IssuerExtensionPaymentPassEntry[]> method reports the list of passes available for Apple Watch.
info
  • System needs to invoke status() handler within 100 ms, or the extension doesn’t display to the user in Apple Wallet.
  • System needs to retrieve passes within 20 s, or it treats the call as a failure and the attempt stops.
  • Don't return payment passes that are already present in the user’s pass library.

List of eligible payment passes displayed to the user, each pass entry contains the following data:

  • identifier: tokenization receipt value received from MeaPushProvisioning.ApplePay.initializeOemTokenization(cardParams) call.
  • title: A name for the pass that the system displays to the user when they add or select the card.
  • art: An image to that the system displays to the user when they add or select the card. The card image should follow the same requirements as in the Functional Requirements for Apple Pay and Direct NFC Access:
    • 1536 x 969 resolution.
    • Size no larger than 4 MB.
    • Have squared (not rounded) corners.
    • Exclude elements that are relevant only for physical cards, which include the card number, embossed characters, hologram, chip contacts.
    • Must be in landscape orientation; if the physical card has a portrait orientation, it must be modified for presentation in landscape orientation.
  • addRequestConfiguration: The configuration that the system uses to add a payment pass.
src/extension/App.IssuerNonUIExtension.ts
import MeaPushProvisioning, { IssuerExtensionHandler, IssuerExtensionPaymentPassEntry, MppCardDataParameters } from '@meawallet/react-native-mpp';
import GroupPreference from 'react-native-default-preference';

// Set your App Group name.
GroupPreference.setName('group.com.issuer.app')

const NonUIExtension = () => {

new class extends IssuerExtensionHandler {

async status() {
return {
requiresAuthentication: true, // Adding a card requires an Authorization UI Extension.
passEntriesAvailable: true, // Payment card is available to add to an iPhone.
remotePassEntriesAvailable: true // Payment card is available to add to an Apple Watch.
}
}

async passEntries() {
return this.getPassEntries()
}

async remotePassEntries() {
return this.getPassEntries(true)
}

async getPassEntries(isRemote = false): Promise<IssuerExtensionPaymentPassEntry[]> {
// Read common shared App Group session data
const session = await GroupPreference.get('session').then(sessionJSON => sessionJSON ? JSON.parse(sessionJSON) : null)

// Use session to retrieve your Issuer's implementation specific card metadata
const cards = await getUserCards(session)

// Check if card with given 4 digit suffix already added to the Wallet
const canAdd = async (card: Card) => {
const cardExists = await ( isRemote ?
MeaPushProvisioning.ApplePay.remoteSecureElementPassExistsWithPrimaryAccountNumberSuffix(card.suffix) :
MeaPushProvisioning.ApplePay.secureElementPassExistsWithPrimaryAccountNumberSuffix(card.suffix)
)
return ! cardExists
}

// Filter eligible cards with `reduce()` instead of `filter()` due to async predicate
const eligibleCards = await cards.reduce(
async (result: Card[], card: Card) => (await canAdd(card) ? [...await result, card] : result),
[]
)

// Retrieve tokenization data for eligible cards and construct pass entry list
return Promise.all(
eligibleCards.map(async (instrument: any) => {
const cardParams = MppCardDataParameters.withCardSecret(card.id, card.generateSecret())
const tokenizationData = await MeaPushProvisioning.ApplePay.initializeOemTokenization(cardParams)
const addRequestConfiguration: MppAddPaymentPassRequestConfiguration = {
style: 'payment',
cardholderName: tokenizationData.cardholderName,
primaryAccountSuffix: tokenizationData.primaryAccountSuffix,
cardDetails: [],
primaryAccountIdentifier: tokenizationData.primaryAccountIdentifier,
paymentNetwork: tokenizationData.networkName,
productIdentifiers: [],
requiresFelicaSecureElement: false
}

const passEntry: IssuerExtensionPaymentPassEntry = {
identifier: tokenizationData.tokenizationReceipt,
title: tokenizationData.cardholderName,
art: cardImage,
addRequestConfiguration: addRequestConfiguration
}
return passEntry
})
)
}
}()

return null
}

export default NonUIExtension

Debugging and Testing

Wallet Extension development requires a physical iOS device.

TestFlight is required for complete push provisioning workflow testing. However, it is possible to test most of the Wallet Extension features locally using Ad-Hoc provisioning profiles.

System Logs

Use Console App to view System Logs.

App Extensions Performance

React Native loading time and JavaScript performance is sufficient for Wallet Extensions, when built in Release mode.

info

React Native console logs are disabled in Release (production) mode.

In Debug mode it takes time for the Metro Bundler to load JavaScript code, initial App Extensions loading time may exceed 100 ms threshold, and the App is not visible in Apple Wallet. Quit and Reopen Apple Wallet App when Metro completes loading the App Extension.

App Extensions Memory Limits

  • Issuer Non-UI App Extension: 55 MB
  • Issuer UI App Extension: 60 MB

JavaScript Environment

  • Hermes

    App Extensions with Hermes in Debug mode allocates ~50 MB of memory during start-up. Even few imports could lead to excessive memory allocation, resulting in process's immediate termination by the System:

    kernel EXC_RESOURCE -> IssuerNonUIExtension[1234] exceeded mem limit: ActiveHard 55 MB (fatal)
    kernel EXC_RESOURCE -> IssuerUIExtension[5678] exceeded mem limit: ActiveHard 60 MB (fatal)

    When App Extension is terminated by the System, no logs are available in React Native console, so review System Logs.

    Memory consumption is substantially better in Release mode, ~16 MB aftert React Native initialization. Build and run App and bundled Extensions in Release mode using the following command:

    npx react-native run-ios --mode Release
  • JavaScriptCore

    JSC offers much smaller initial memory fooprint of ~25 MB in Debug mode. If you would like to use Fast Refresh feature and React Native logging, you could temporarily switch to JSC JavaScript engine:

    ios/Podfile
    use_react_native!(
    :path => config[:reactNativePath],
    # Hermes is now enabled by default. Disable by setting this flag to false.
    :hermes_enabled => false,
    )