This guide explains how to ship wallet passes that interact with NFC readers — at point-of-sale, turnstile, or kiosk. Both platforms support contactless identification, but they use entirely different protocols, payload shapes, and approval workflows. A pass that is NFC-capable on one platform is not automatically NFC-capable on the other.

The library exposes a small shared surface (nfc_message and friends on PassData, plus a few class-level flags for Google) that emits the right bytes on each side. Approval and key material are on you.

Overview

NFC-enabled wallet passes let a phone tap a reader and transmit a short payload — typically a member or order identifier — without unlocking, opening the wallet app, or even waking the screen.

Each platform gates NFC behind a separate approval process:

  • Apple Wallet (VAS) — Apple's Value Added Services protocol. Requires a special entitlement on your Apple Developer account, plus a P-256 keypair you generate locally.
  • Google Wallet (Smart Tap) — Google's contactless protocol. Requires partner approval and one or more redemption issuer IDs from Google.

Both gates are independent. Most issuers ship one before the other; this library lets you light up Apple-only or Google-only NFC without disturbing the other platform.

NFC most commonly applies to :store_card (loyalty), :event_ticket, and sometimes :coupon passes. See Pass Types for the full mapping.

Concepts

Apple VAS — encrypted handshake with a per-tap nonce

Apple's Value Added Services protocol is interactive. When a phone taps a VAS-aware reader, the reader sends a nonce; the phone uses your pass's encryption public key to derive an ephemeral key, encrypts the pass's NFC message plus metadata under that key, and returns the ciphertext. The reader's vendor — which holds the matching private key you generated with mix wallet_passes.gen.apple_nfc_key — decrypts the response and recovers your message.

Key consequences:

  • The reader vendor needs your private key (nfc_private.pem). The pass carries only the public key.
  • The payload (nfc_message) is bounded to 64 bytes by Apple's spec; this library raises if you exceed it.
  • Apple counts payload size in bytes, not characters — multibyte UTF-8 characters can push you over even when the string looks short.
  • Apple requires an entitlement on your pass-type identifier before NFC activates. Without it, the nfc dictionary in pass.json is ignored.

Google Smart Tap — server-registered redemption issuer

Google's Smart Tap protocol does not involve a custom keypair on your end. Google maintains a redemption issuer registry: each terminal vendor has a registered ID, and a Smart Tap-enabled pass lists which IDs are authorized to read it. At tap time, the terminal authenticates to Google's wallet stack on the phone using its redemption-issuer credentials; Google then releases the pass's smartTapRedemptionValue.

Key consequences:

  • Smart Tap is gated by Google partner approval, not a downloadable entitlement. You contact Google Wallet support to enable it for your issuer account, and they provide redemption issuer IDs.
  • The payload is set on the object (smartTapRedemptionValue); the class carries the enableSmartTap flag plus the redemptionIssuers allowlist.
  • Google does not enforce a strict payload length, but keep redemption values short — most terminals expect a short identifier.

Apple Wallet (VAS)

Entitlement

Apple NFC passes require an entitlement Apple grants per pass-type identifier. Apply at developer.apple.com/contact/passkit. Until the entitlement is granted, iOS silently ignores the nfc dictionary you ship in pass.json. The pass still installs and displays, but reader taps do nothing. See Apple Wallet for pass-type-ID and bundle setup that precedes this step.

Generating the keypair

You generate the VAS keypair locally:

$ mix wallet_passes.gen.apple_nfc_key

This writes three files into ./nfc_keys/ (pass an output directory to override). Existing files are never overwritten — losing a private key already in a reader vendor's hands is painful, so the task refuses to clobber.

FilePurpose
nfc_private.pemPKCS#8 private key. Hand this to your VAS reader vendor.
nfc_public.pemSPKI public key in compressed-point form (PEM-armored).
nfc_public.b64Single-line base64 of the SPKI key — paste into :nfc_encryption_public_key.

The key is P-256 (prime256v1) in compressed-point form. Apple rejects uncompressed-point keys; the task warns if the resulting base64 is suspiciously long (compressed is ~80 chars; uncompressed is ~120). The task shells out to openssl — ensure it's on PATH.

Fields on PassData

Three fields drive the Apple nfc dictionary:

pass_data = WalletPasses.PassData.new(
  serial_number: "MEMBER-001",
  pass_type: :store_card,
  nfc_message: "member-id:MEMBER-001",
  nfc_encryption_public_key: "nfc_keys/nfc_public.b64" |> File.read!() |> String.trim(),
  nfc_requires_authentication: false,
)
  • :nfc_message — the payload the reader receives after decryption. Required for NFC to activate. Must be at most 64 bytes (ArgumentError otherwise; multibyte UTF-8 counts as multiple bytes).
  • :nfc_encryption_public_key — base64-encoded SPKI public key from nfc_public.b64. Required. Validated as decodable base64 at build time; whitespace and newlines are tolerated.
  • :nfc_requires_authentication — optional boolean. When true, iOS requires the user to authenticate (Face ID / Touch ID / passcode) before releasing the payload. When false, the tap works on a locked phone. Omit (leave nil) to let iOS use its default.

If either :nfc_message or :nfc_encryption_public_key is nil, the library omits the entire nfc dictionary — there's no half-configured state.

The emitted pass.json fragment:

{
  "nfc": {
    "message": "member-id:MEMBER-001",
    "encryptionPublicKey": "MDkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDIgAD...",
    "requiresAuthentication": false
  }
}

requiresAuthentication only appears when you set the field explicitly.

Google Wallet (Smart Tap)

Partner approval

Contact Google Wallet support through the issuer console to request Smart Tap activation. They'll provision a collector ID (your issuer's redemption identity), approve your account for the Smart Tap APIs, and provide the redemption issuer IDs of any third-party terminal vendors you need to authorize. There's no local keypair step.

enable_smart_tap and redemption_issuers on the class

Smart Tap is a class-level setting — the class declares that all of its passes can be read via NFC, and lists which redemption issuer IDs are allowed:

WalletPasses.Google.Api.create_or_update_class(%{
  id: "loyalty_class",
  issuer_name: "My Store",
  event_name: "Loyalty Card",
  pass_type: :store_card,
  enable_smart_tap: true,
  redemption_issuers: ["1234567890", "9876543210"],
})
  • :enable_smart_tap — boolean. When truthy, the library sets enableSmartTap: true on the class JSON. When falsy or omitted, the field is omitted entirely.
  • :redemption_issuers — list of redemption issuer ID strings. Only emitted when :enable_smart_tap is truthy and the list is non-nil.

The class JSON fragment:

{
  "enableSmartTap": true,
  "redemptionIssuers": ["1234567890", "9876543210"]
}

The per-object payload

On each pass object, set :nfc_message on the underlying PassData — the same field that drives Apple's payload. The library maps it onto smartTapRedemptionValue:

pass_data = WalletPasses.PassData.new(
  serial_number: "MEMBER-001",
  pass_type: :store_card,
  nfc_message: "REDEEM-MEMBER-001",
)

{:ok, save_url} = WalletPasses.google_save_url(pass_data, google_visual,
  class_config: %{
    id: "loyalty_class",
    issuer_name: "My Store",
    event_name: "Loyalty Card",
    pass_type: :store_card,
    enable_smart_tap: true,
    redemption_issuers: ["1234567890"],
  }
)

The emitted object JSON fragment:

{
  "smartTapRedemptionValue": "REDEEM-MEMBER-001"
}

If nfc_message is nil, the library omits the field. Unlike Apple, Google doesn't need a public key on the object — only the class-level allowlist.

Differences and Trade-offs

The same nfc_message string flows to both platforms, but the surrounding semantics differ:

ConcernApple VASGoogle Smart Tap
Approval gateApple entitlement (per pass-type ID)Google partner approval (per issuer)
KeypairYou generate P-256 locallyNone — Google handles transport
Where payload livesnfc.message on the pass (pass.json)smartTapRedemptionValue on the object
Class-level configNoneenableSmartTap + redemptionIssuers
Payload size limit64 bytes (strict)No hard limit; keep it short
Encoding semanticsBytes after AES-GCM decryptionPlain string as stored
Authentication gatingrequiresAuthentication: true opt-inNot configurable per-pass
Re-issue to changeYes — .pkpass is immutableNo — update_google_pass/3 suffices

Payload-design implications:

  • Use the same nfc_message for both platforms when you can. A short redemption identifier (e.g. "LOY-AB12-CD34") fits inside 64 bytes, reads cleanly on both, and avoids per-platform divergence in your back-end's redemption logic.
  • Avoid embedding JSON or signed tokens. They blow Apple's 64-byte cap fast, and are redundant on Google — the terminal already authenticates via the redemption issuer registry.
  • Keep nfc_message opaque-but-stable. It's not localized, rendered, or displayed to users. See Localization for the full list of fields that bypass the translation pipeline.

Troubleshooting

"I shipped a .pkpass with NFC fields, but the reader sees nothing"

Most likely your pass-type identifier doesn't yet have the VAS entitlement. Verify by inspecting the pass on the device — if iOS shows it without an NFC indicator near the top of the pass detail view, the entitlement isn't active.

ArgumentError: nfc_message must be at most 64 bytes (Apple VAS limit)

You're over the cap. Check:

  • Multibyte characters: "café" is 5 bytes, not 4.
  • Embedded JSON: serializing a struct in nfc_message rarely fits.
  • Trailing whitespace from File.read!/1 — trim before assigning.

Drop to a short identifier and resolve to the full payload server-side via your redemption lookup.

ArgumentError: nfc_encryption_public_key must be base64-encoded ...

The string isn't valid base64. Make sure you're pasting the contents of nfc_public.b64 (single-line) and not nfc_public.pem (PEM-armored with -----BEGIN PUBLIC KEY----- headers). Embedded whitespace and newlines are tolerated; PEM headers are not.

"Apple NFC works, Google Smart Tap doesn't"

Run through:

  1. Is enable_smart_tap: true on the class, not the object? Smart Tap is class-level.
  2. Are the redemption issuer IDs correct? Using the wrong one silently fails at tap time.
  3. Has Google approved Smart Tap for your issuer account? Without approval, enableSmartTap: true is accepted into the class but never activates on devices.
  4. Did you re-publish the class after adding enable_smart_tap? Class changes propagate the next time devices sync; updating objects alone is not enough.

"Apple shows the NFC chevron but the payload is wrong"

iOS caches passes aggressively. After re-issuing a pass with a new nfc_message, push devices with WalletPasses.notify_apple_devices(serial_number) so they re-fetch. Without the push, devices update at their own polling cadence (hours to days).

API Reference

Functions and fields that participate in NFC / Smart Tap configuration:

See also: Getting Started for cert/config setup that precedes NFC, Apple Wallet for VAS bundle details, Google Wallet for class-level Smart Tap context, and Pass Types for the pass types NFC most often applies to.