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.3"},
  ]
end

Generate and run the database migrations:

$ mix wallet_passes.gen.migration
$ mix ecto.migrate

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"

# 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.

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 Apple callback router

Apple devices call back to your server to register for updates and fetch the latest pass. Mount the router in your Phoenix app:

# router.ex
forward "/passes/apple", WalletPasses.Apple.Router

4. Send push updates

WalletPasses.notify_apple_devices("SERIAL-NUMBER")

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.

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()

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.