WalletPasses

Apple Wallet and Google Wallet pass generation, management, and remote updates for Elixir.

Features

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.

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:

System Requirements

License

MIT -- see LICENSE for details.