We have Wallet and Watch application on iPhone. Both of them can add card and then waiting for activation.
However, When the same card is added to Wallet and Watch respectively, waiting for the app-to-app mode to be activated. Client doesn't aware the source application. Because deeplink is exactly the same.
Any adivse how does the client have to choose which card to activate?
Hi @zhangzw,
You wrote:
[...] When the same card is added to Wallet and Watch respectively, waiting for the app-to-app mode to be activated. Client doesn't aware the source application. Because deep link is exactly the same. [...] Any adivse how does the client have to choose which card to activate?
When a card is added via PKAddPaymentPassViewController, the deeplink back to the issuer app has no indicator of whether it was triggered from Wallet provisioning on iOS or watchOS. You must resolve this yourself, using one of several strategies:
- Strategy 1: Query
PKPassLibraryactivation state - Strategy 2: Encode context in the deeplink at provisioning time
- Strategy 3: Use
PKIssuerProvisioningExtension(iOS 14 and later) - Strategy 4: Server-side session token
Strategy 1: Query PKPassLibrary activation state
After receiving the deeplink, query both local and remote passes and filter by activation state:
func resolveActivationTarget() -> ActivationTarget {
let library = PKPassLibrary()
// iPhone Wallet passes
let localPendingPasses = library.passes(of: .secureElement)
.compactMap { $0 as? PKPaymentPass }
.filter { $0.activationState == .requiresActivation }
// Apple Watch passes (remote)
let remotePendingPasses = library.remoteSecureElementPasses()
.filter { $0.activationState == .requiresActivation }
switch (localPendingPasses.isEmpty, remotePendingPasses.isEmpty) {
case (false, true):
return .wallet(localPendingPasses)
case (true, false):
return .watch(remotePendingPasses)
case (false, false):
return .both(local: localPendingPasses, remote: remotePendingPasses)
default:
return .none
}
}
enum ActivationTarget {
case wallet([PKPaymentPass])
case watch([PKPaymentPass])
case both(local: [PKPaymentPass], remote: [PKPaymentPass])
case none
}
Note: remoteSecureElementPasses() exclusively returns passes from Apple Watch.
With this approach, if the user adds a card to both iPhone and Apple Watch before activating either, both cards will be returned. In that case, you should present a disambiguation UI:
case .both(let local, let remote):
presentActivationChoice(
options: [
ActivationOption(label: "iPhone", passes: local),
ActivationOption(label: "Apple Watch", passes: remote),
]
)
This disambiguation flow is the safest fallback for any of the strategies mentioned.
Strategy 2: Encode context in the deeplink at provisioning time
Before the user even adds the card, encode the source into the deeplink URL your provisioning flow uses. This requires you to control how the deeplink is launched.
// When starting iPhone provisioning
let walletDeeplink = "yourapp://activate?source=wallet&cardId=\(cardId)"
// When starting Apple Watch provisioning
let watchDeeplink = "yourapp://activate?source=watch&cardId=\(cardId)"
Then, in your deeplink handler:
func handleDeeplink(_ url: URL) {
let components = URLComponents(url: url, resolvingAgainstBaseURL: false)
let source = components?.queryItems?.first(where: { $0.name == "source" })?.value
let cardId = components?.queryItems?.first(where: { $0.name == "cardId" })?.value
switch source {
case "wallet": activateForWallet(cardId: cardId)
case "watch": activateForWatch(cardId: cardId)
default: resolveActivationTarget() // Fallback to Strategy 1
}
}
Strategy 3: Use PKIssuerProvisioningExtensionHandler (iOS 14 and later)
If you are using PKIssuerProvisioningExtensionHandler, PassKit gives you separate entry points for iPhone and Apple Watch. You never need to guess the source because the system calls different methods:
class MyProvisioningExtensionHandler: PKIssuerProvisioningExtensionHandler {
// Called for iPhone Wallet cards
override func passEntries(completion: @escaping ([PKIssuerProvisioningExtensionPassEntry]) -> Void) {
let entry = buildPassEntry(target: .wallet)
completion([entry])
}
// Called for Apple Watch cards
override func remotePassEntries(completion: @escaping ([PKIssuerProvisioningExtensionPassEntry]) -> Void) {
let entry = buildPassEntry(target: .watch)
completion([entry])
}
}
You can store which method was invoked (e.g., in UserDefaults or a shared app group) so that when your main issuer app opens via deeplink, it already knows the target.
Strategy 4: Server-side session token
Generate a unique session token per provisioning attempt on your backend and embed it in the deeplink.
yourapp://activate?sessionToken=abc123xyz
Your sever then stores:
{
"sessionToken": "abc123xyz",
"source": "watch",
"cardId": "card_9a45c9376",
"status": "pending_activation"
}
When the deeplink is invoked, the app calls your backend with the token to retrieve the full context including source.
Important: Strategy 3 and 4 are recommended, as they are the most robust approaches for complex multi-device scenarios.
Cheers,
Paris X Pinkney | WWDR | DTS Engineer