WalletPasses
Apple Wallet and Google Wallet pass generation, management, and remote updates for Elixir.
Features
- Apple Wallet: Build signed
.pkpassbundles, 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
ECv2SigningOnlysignature verification - Unified event handling: One
WalletPasses.EventHandlerbehaviour reacts to pass-added / removed / fetched events from both platforms, dispatched asynchronously under supervision - Wallet presence query:
WalletPasses.wallet_presence/1reports whether a pass is currently saved on either platform - Platform-agnostic data model:
PassDatastruct for content, separateApple.Visual/Google.Visualfor 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:
:telemetryevents 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"},
]
endGenerate 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
end2. 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
endWire 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.
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 for details.
Why not passbook?
The passbook (ex_passbook) package is the only other Elixir library for .pkpass generation. However:
- Missing
authenticationTokensupport --passbookdoesn't support theauthenticationTokenfield, which is required for the pass update lifecycle (Apple devices use it to authenticate callback requests) - URL camelization bug --
passbookhas 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
.pkpassbuilding - No runtime dependency on OpenSSL -- This library uses a pure Erlang PKCS#7 implementation for
.pkpasssigning, whilepassbookshells out toopenssl smime
System Requirements
- PostgreSQL -- required for pass persistence (via Ecto)
License
MIT -- see LICENSE for details.