# NFC & Smart Tap
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](pass-types.md) 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](https://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](apple-wallet.md) for pass-type-ID and bundle setup that
precedes this step.
### Generating the keypair
You generate the VAS keypair locally:
```bash
$ 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:
```elixir
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:
```json
{
"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_issuers` on 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 in `redemption_issuers`
> makes 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:
```elixir
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 sets
`enableSmartTap: true` on 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_tap` is truthy and the list is
non-nil.
The class JSON fragment:
```json
{
"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`:
```elixir
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:
```json
{
"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_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](localization.md) 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:
- `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 the `nfc`
dictionary into `pass.json` when both `:nfc_message` and
`:nfc_encryption_public_key` are set. Validates the 64-byte limit and
base64 key shape.
- `WalletPasses.Google.Api.build_pass_object/3` — emits
`smartTapRedemptionValue` when `:nfc_message` is set on `PassData`.
- `WalletPasses.Google.Api.build_class_object/1` — emits
`enableSmartTap` and `redemptionIssuers` on the class when
`:enable_smart_tap` is truthy. See [Google Wallet](google-wallet.md)
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](getting-started.md) for cert/config setup
that precedes NFC, [Apple Wallet](apple-wallet.md) for VAS bundle
details, [Google Wallet](google-wallet.md) for class-level Smart Tap
context, and [Pass Types](pass-types.md) for the pass types NFC most
often applies to.