How to difference source application during In-App Verification

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?

Answered by DTS Engineer in 893624022

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 PKPassLibrary activation 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

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 PKPassLibrary activation 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

How to difference source application during In-App Verification
 
 
Q