# WalletPasses
Apple Wallet and Google Wallet pass generation, management, and remote updates for Elixir.
## Features
- **Apple Wallet:** Build signed `.pkpass` bundles, handle device registration callbacks, send silent APNs pushes
- **Google Wallet:** Create/update pass objects and classes, generate "Save to Google Wallet" URLs, handle save/delete callbacks with full `ECv2SigningOnly` signature verification
- **Unified event handling:** One `WalletPasses.EventHandler` behaviour reacts to pass-added / removed / fetched events from both platforms, dispatched asynchronously under supervision
- **Wallet presence query:** `WalletPasses.wallet_presence/1` reports whether a pass is currently saved on either platform
- **Platform-agnostic data model:** `PassData` struct for content, separate `Apple.Visual` / `Google.Visual` for platform-specific styling
- **Theme helper:** Convert shared colors into platform-specific visual configs
- **QR code generation:** SVG and PNG output
- **Ecto persistence:** Separate per-platform tables with migration generator
- **Telemetry:** `:telemetry` events on every Apple/Google API call, push notification, pkpass build, save URL JWT, callback verification, and event-handler dispatch
- **Optional add-ons:** LiveView preview components, Oban background sync worker
## Installation
Add `wallet_passes` to your list of dependencies in `mix.exs`:
def deps do
[
{:wallet_passes, "~> 0.6"},
]
end
Generate and run the database migrations:
$ mix wallet_passes.gen.migration
$ mix ecto.migrate
Upgrading from `0.5.x`? `0.6.0` adds a `wallet_passes_google_callbacks` audit table. Re-run `mix wallet_passes.gen.migration` and migrate — the generator emits only the new migration files; existing tables are untouched.
## Configuration
# config/config.exs
config :wallet_passes,
repo: MyApp.Repo,
pass_data_provider: MyApp.WalletPassProvider,
apple_pass_type_id: "pass.com.example.mypass",
apple_web_service_url: "https://yourdomain.com/passes/apple",
google_callback_url: "https://yourdomain.com/passes/google/callback",
event_handler: MyApp.WalletEventHandler
# config/runtime.exs
config :wallet_passes,
apple_team_id: System.get_env("APPLE_TEAM_ID"),
apple_pass_type_cert: System.get_env("APPLE_PASS_TYPE_CERT"),
apple_pass_type_key: System.get_env("APPLE_PASS_TYPE_KEY"),
apple_wwdr_cert: System.get_env("APPLE_WWDR_CERT"),
google_issuer_id: System.get_env("GOOGLE_WALLET_ISSUER_ID"),
google_service_account_json: System.get_env("GOOGLE_WALLET_SERVICE_ACCOUNT_JSON")
Certificate/key values accept file paths, PEM strings, or base64-encoded values.
`:google_callback_url` and `:event_handler` are both optional. Without `:google_callback_url`, no `callbackOptions` is registered on the Google class object and Google won't send save/delete callbacks. Without `:event_handler`, lifecycle events fire but are silently ignored.
## Quick Start
### 1. Implement the PassDataProvider
The library needs to look up pass data autonomously (e.g., when Apple requests an updated pass). Implement the behaviour:
defmodule MyApp.WalletPassProvider do
@behaviour WalletPasses.PassDataProvider
@impl true
def build_pass_data(serial_number) do
case MyApp.find_by_serial(serial_number) do
nil -> {:error, :not_found}
record ->
{:ok, %{
pass_data: %WalletPasses.PassData{
serial_number: serial_number,
event_name: record.event_name,
holder_name: record.holder_name,
primary_fields: [{"name", "Name", record.holder_name}],
# ... more fields
},
apple: %WalletPasses.Apple.Visual{
background_color: "#1A1A1A",
foreground_color: "#FFFFFF",
label_color: "#D4A843",
icon_path: "/path/to/icon.png",
},
google: %WalletPasses.Google.Visual{
background_color: "#1A1A1A",
logo_uri: "https://example.com/logo.png",
},
}}
end
end
end
### 2. Generate passes
# Build an Apple .pkpass
{:ok, pkpass_binary} = WalletPasses.build_apple_pass(pass_data, apple_visual)
# Get a Google Wallet save URL
{:ok, url} = WalletPasses.google_save_url(pass_data, google_visual)
### 3. Mount the callback routers
Apple devices register with your server for push notifications and pull updated passes. Google's servers POST signed callbacks when users save or remove a pass. Mount both routers in your Phoenix app, **outside** any CSRF-protected pipeline (neither sends a CSRF token):
# router.ex
forward "/passes/apple", WalletPasses.Apple.Router
forward "/passes/google", WalletPasses.Google.Router
The Google Router endpoint is `POST /callback`, so the full URL Google will hit is whatever you set `:google_callback_url` to (e.g. `https://yourdomain.com/passes/google/callback`). Every callback is verified against Google's published `ECv2SigningOnly` keys before persistence — no shared secrets, no per-request signing setup on your side.
### 4. Send push updates
WalletPasses.notify_apple_devices("SERIAL-NUMBER")
### 5. React to pass lifecycle events
Implement the `WalletPasses.EventHandler` behaviour to react to passes being added, removed, or fetched on either platform:
defmodule MyApp.WalletEventHandler do
@behaviour WalletPasses.EventHandler
@impl true
def on_pass_added(serial, :google, _meta) do
MyApp.Orders.mark_saved_to_wallet(serial)
end
def on_pass_added(serial, :apple, %{device_library_id: device, push_token: token}) do
MyApp.Telemetry.track_apple_register(serial, device, token)
end
@impl true
def on_pass_removed(serial, :google, _meta) do
# Definitive: user removed the pass from their Google Wallet.
MyApp.Orders.mark_pass_removed(serial)
end
def on_pass_removed(_serial, :apple, _meta) do
# Apple unregister fires on push-token rotation, app uninstall, OR genuine removal.
# Treat as a "device unreachable" signal, not an authoritative "user deleted" signal.
:ok
end
end
Wire it in:
config :wallet_passes, :event_handler, MyApp.WalletEventHandler
Callbacks run asynchronously under a `Task.Supervisor` so a slow handler can never extend Apple's iOS response time or Google's callback timeout. Exceptions are captured, logged, and reported via telemetry. All three callbacks (`on_pass_added`, `on_pass_removed`, `on_pass_fetched`) are optional — implement only what you care about.
If `:event_handler` is configured but the module exports none of the optional callbacks (typo, missing `@behaviour`), a one-time warning is logged at boot.
### 6. Query current wallet presence
case WalletPasses.wallet_presence("SERIAL-NUMBER") do
%{apple: true, google: true} -> "Saved on both"
%{apple: true, google: _} -> "Saved on Apple"
%{apple: false, google: true} -> "Saved on Google"
%{apple: false, google: false} -> "Removed from Google"
%{apple: false, google: nil} -> "Not yet saved"
end
`:google` is `boolean() | nil`. `nil` means no callback has been recorded yet (either the pass was never saved, or `:google_callback_url` isn't configured) — distinct from `false`, which means Google explicitly told us the pass was deleted. `:apple` is "at least one device is reachable for push" — see the `on_pass_removed/3` caveat above for why it isn't authoritative on its own.
## Theme Helper
Use the `Theme` struct to share colors across platforms:
theme = %WalletPasses.Theme{
background_color: "#1A1A1A",
foreground_color: "#FFFFFF",
label_color: "#D4A843",
logo_text: "My Event",
}
apple_visual = theme
|> WalletPasses.Theme.to_apple_visual()
|> struct!(icon_path: "/path/to/icon.png", strip_image_path: "/path/to/strip.png")
google_visual = theme
|> WalletPasses.Theme.to_google_visual()
|> struct!(logo_uri: "https://example.com/logo.png", hero_image_uri: "https://example.com/hero.png")
## NFC Passes
### Apple Wallet (VAS Protocol)
Add NFC fields to your `PassData` to enable tap-to-identify:
pass_data = PassData.new(
serial_number: "MEMBER-001",
nfc_message: "member-id:MEMBER-001",
nfc_encryption_public_key: "MDkwEwYH...", # Base64 X.509 ECDH P-256 public key
nfc_requires_authentication: false,
# ... other fields
)
Both `nfc_message` and `nfc_encryption_public_key` are required -- if either is nil, the NFC dictionary is omitted from the pass.
To generate the keypair in the format Apple expects (PKCS#8 private key, compressed-point SPKI public key, base64-encoded), run:
$ mix wallet_passes.gen.apple_nfc_key
This writes three files into `./nfc_keys/` (override with a path argument). Hand `nfc_private.pem` to your VAS reader vendor and paste the contents of `nfc_public.b64` into `:nfc_encryption_public_key`. Requires `openssl` on `PATH`.
**Note:** Apple NFC passes require a special entitlement from Apple. Apply at [developer.apple.com/contact/passkit](https://developer.apple.com/contact/passkit/).
### Google Wallet (Smart Tap)
Set `nfc_message` on the pass data (used as the Smart Tap redemption value), and enable Smart Tap on the class:
pass_data = PassData.new(
serial_number: "MEMBER-001",
nfc_message: "REDEEM-MEMBER-001",
# ... other fields
)
# When creating the class, enable Smart Tap:
WalletPasses.Google.Api.create_or_update_class(%{
id: "loyalty_class",
issuer_name: "My Store",
event_name: "Loyalty Card",
enable_smart_tap: true,
redemption_issuers: ["YOUR_REDEMPTION_ISSUER_ID"],
})
**Note:** Google Smart Tap requires partner approval. Contact Google Wallet support to enable Smart Tap for your issuer account.
## Optional Add-ons
### Preview Components (Phoenix LiveView)
Add `{:phoenix_live_view, "~> 1.0"}` to your deps, then:
import WalletPasses.Preview.Components
<.apple_pass_preview pass_json={@apple_json} qr_svg={@qr_svg} />
<.google_pass_preview pass_object={@google_obj} qr_svg={@qr_svg} />
### Background Sync (Oban)
Add `{:oban, "~> 2.18"}` to your deps, then:
# Sync specific passes
WalletPasses.Sync.sync(["SERIAL-1", "SERIAL-2"])
# Sync all passes in the database
WalletPasses.Sync.sync_all()
## Development
A bundled Phoenix app at `dev/wallet_passes_dev/` provides a visual sandbox for working on the library — live pass previews, editable form inputs, and a mock API activity log. No real Apple/Google credentials needed.
cd dev/wallet_passes_dev
mix setup # deps, db, migrations, assets
mix phx.server # http://localhost:4000
See [`dev/README.md`](dev/README.md) for details.
## Why not passbook?
The [`passbook`](https://hex.pm/packages/passbook) (ex_passbook) package is the only other Elixir library for `.pkpass` generation. However:
- **Missing `authenticationToken` support** -- `passbook` doesn't support the `authenticationToken` field, which is required for the pass update lifecycle (Apple devices use it to authenticate callback requests)
- **URL camelization bug** -- `passbook` has a known bug that mis-cases fields containing "url"
- **Full lifecycle** -- This library owns the entire pass lifecycle (generation, callbacks, push updates, Google Wallet API) rather than just `.pkpass` building
- **No runtime dependency on OpenSSL** -- This library uses a pure Erlang PKCS#7 implementation for `.pkpass` signing, while `passbook` shells out to `openssl smime`
## System Requirements
- **PostgreSQL** -- required for pass persistence (via Ecto)
## License
MIT -- see [LICENSE](LICENSE) for details.