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
nfcdictionary inpass.jsonis 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 theenableSmartTapflag plus theredemptionIssuersallowlist. - 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.
| File | Purpose |
|---|---|
nfc_private.pem | PKCS#8 private key. Hand this to your VAS reader vendor. |
nfc_public.pem | SPKI public key in compressed-point form (PEM-armored). |
nfc_public.b64 | Single-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 (ArgumentErrorotherwise; multibyte UTF-8 counts as multiple bytes).:nfc_encryption_public_key— base64-encoded SPKI public key fromnfc_public.b64. Required. Validated as decodable base64 at build time; whitespace and newlines are tolerated.:nfc_requires_authentication— optional boolean. Whentrue, iOS requires the user to authenticate (Face ID / Touch ID / passcode) before releasing the payload. Whenfalse, the tap works on a locked phone. Omit (leavenil) 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 approve your account for the Smart Tap APIs and provision a Collector ID — an 8-digit number that identifies your terminal/reader in the tap handshake. There's no local keypair step (Google holds your registered public key).
Issuer ID vs Collector ID — do not mix these up. They are different identifiers with different homes:
- Issuer ID — your ~19-digit Google Wallet issuer account ID (the account that holds the Smart Tap auth keys). This is what goes in
redemption_issuerson the class.- Collector ID — the 8-digit terminal identifier. It is provisioned onto the reader hardware, and must never appear in
redemption_issuers. A Collector ID inredemption_issuersmakes Google Wallet treat the pass as non-redeemable: no contactless indicator on the pass face and no successful tap.
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 issuers (by issuer ID) are allowed to redeem them:
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,
# Issuer IDs (~19 digits) — NOT Collector IDs. For self-redemption
# this is simply your own issuer ID; add other issuers' IDs only when
# third parties redeem your passes.
redemption_issuers: ["3388000000012345678"],
}):enable_smart_tap— boolean. When truthy, the library setsenableSmartTap: trueon the class JSON. When falsy or omitted, the field is omitted entirely.:redemption_issuers— list of issuer ID strings (the accounts authorized to redeem over Smart Tap; each must have a Smart Tap key configured). Use issuer IDs, never Collector IDs — see the warning above. Only emitted when:enable_smart_tapis truthy and the list is non-nil.
The class JSON fragment:
{
"enableSmartTap": true,
"redemptionIssuers": ["3388000000012345678"]
}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:
| Concern | Apple VAS | Google Smart Tap |
|---|---|---|
| Approval gate | Apple entitlement (per pass-type ID) | Google partner approval (per issuer) |
| Keypair | You generate P-256 locally | None — Google handles transport |
| Where payload lives | nfc.message on the pass (pass.json) | smartTapRedemptionValue on the object |
| Class-level config | None | enableSmartTap + redemptionIssuers |
| Payload size limit | 64 bytes (strict) | No hard limit; keep it short |
| Encoding semantics | Bytes after AES-GCM decryption | Plain string as stored |
| Authentication gating | requiresAuthentication: true opt-in | Not configurable per-pass |
| Re-issue to change | Yes — .pkpass is immutable | No — update_google_pass/3 suffices |
Payload-design implications:
- Use the same
nfc_messagefor 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_messageopaque-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_messagerarely 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:
- Is
enable_smart_tap: trueon the class, not the object? Smart Tap is class-level. - Are the redemption issuer IDs correct? Using the wrong one silently fails at tap time.
- Has Google approved Smart Tap for your issuer account? Without
approval,
enableSmartTap: trueis accepted into the class but never activates on devices. - 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:
WalletPasses.PassData— fields:nfc_message,:nfc_encryption_public_key,:nfc_requires_authentication. Apple consumes all three; Google consumes only:nfc_message.Mix.Tasks.WalletPasses.Gen.AppleNfcKey—mix wallet_passes.gen.apple_nfc_key [output_dir]. Generates the P-256 keypair Apple expects (PKCS#8 private, compressed-SPKI public, base64-encoded public).WalletPasses.Apple.Builder.build_pass_json/3— emits thenfcdictionary intopass.jsonwhen both:nfc_messageand:nfc_encryption_public_keyare set. Validates the 64-byte limit and base64 key shape.WalletPasses.Google.Api.build_pass_object/3— emitssmartTapRedemptionValuewhen:nfc_messageis set onPassData.WalletPasses.Google.Api.build_class_object/1— emitsenableSmartTapandredemptionIssuerson the class when:enable_smart_tapis truthy. See Google Wallet for the full class options reference.WalletPasses.Google.Api.create_or_update_class/2— class-level Smart Tap flags propagate through this call.
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.