This guide takes you from a fresh Elixir application to a working Apple .pkpass and Google Wallet save URL. It assumes the README's install snippet (adding the dep, creating a repo) is done and picks up from there.

If you have never integrated with Apple Wallet or Google Wallet before, the Concepts section explains the underlying mechanics. If you have, skim it and jump to Apple Credentials, Google Credentials, or the End-to-End Walkthrough.

Concepts

Wallet passes are platform-specific bundles that live on a user's device. The two platforms diverge sharply on how passes are built, signed, delivered, and updated — wallet_passes papers over most of that for you, but knowing the shape of each helps when something goes wrong.

Apple Wallet

An Apple Wallet pass is a .pkpass file: a ZIP archive containing a pass.json describing the pass, image assets (icon.png, strip.png, thumbnail.png, plus @2x and @3x retina variants), a manifest.json listing SHA1 hashes of every file, and a PKCS#7 detached signature over the manifest. The signature chains up through:

  • The pass-type certificate — issued to your Apple Developer account, scoped to one pass type ID (e.g. pass.com.example.mypass).
  • The pass-type private key — generated locally when you created the CSR for the pass-type certificate.
  • The WWDR intermediate certificate — Apple's intermediate CA. The same WWDR cert is shared across every Apple developer in the world; you download it once.

Devices fetch the bundle directly (your server sends the .pkpass binary), verify the signature, and render pass.json natively. To update a pass on existing devices, the library calls your webServiceURL endpoint after Apple sends a silent APNs push to every device registered for the serial number.

Google Wallet

A Google Wallet pass is two server-side records: a class (the shared template — issuer name, event name, logo URI, redemption issuers for NFC, etc.) and an object (the per-pass instance — serial number, holder name, field values, current state). Both live on Google's servers; the device only holds a reference.

You authenticate to Google's Wallet API with a service account — a non-human Google Cloud identity whose private key the library uses to mint short-lived OAuth access tokens (cached for ~55 minutes). To save a pass to a user's wallet you generate a save URL: a signed JWT containing the pass object payload, wrapped in a https://pay.google.com/gp/v/save/ link. When the user taps the link, Google's servers create or update the object in their database and add a reference to the user's wallet.

Updates are server-side and instantaneous: PATCH the object on Google's API and the next sync (typically within minutes) reflects the change on every device that has the pass saved. Save/delete events fire as POSTs to a callback URL you configure on the class.

Where this library fits

  • You decide what a pass looks like by populating a PassData struct plus a per-platform Apple.Visual or Google.Visual.
  • The library turns those structs into a signed .pkpass binary or a Google save URL, persists the pass record in your Postgres database, handles Apple's device registration and update protocol, verifies incoming Google callbacks against Google's published ECv2SigningOnly keys, and pushes APNs notifications when content changes.
  • You mount two Plug routers (Apple.Router, Google.Router) somewhere in your Phoenix app, implement the PassDataProvider behaviour, and the lifecycle works end-to-end.

Prerequisites

Before configuring the library you need:

  1. An Apple Developer Program membership ($99/year) — for the pass-type certificate.
  2. A Google Cloud project with the Google Wallet API enabled and a service account whose JSON key is downloadable.
  3. A Postgres database reachable from your app — the library persists pass records via Ecto.
  4. A publicly reachable HTTPS endpoint — Apple devices and Google's servers both need to call your app. For local development, use a tunnel (e.g. cloudflared, ngrok).

Apple and Google credentials take some clicking through portals to obtain; the next two sections cover them step-by-step.

Apple Credentials

You need three files: a pass-type certificate, its matching private key, and the WWDR intermediate certificate.

1. Register a pass type ID

In the Apple Developer portal:

  1. Go to Certificates, Identifiers & ProfilesIdentifiers.
  2. Click + → choose Pass Type IDsContinue.
  3. Enter a description (e.g. "My App Loyalty Card") and a reverse-DNS identifier (e.g. pass.com.example.mypass). The identifier must start with pass. and must be globally unique across all Apple developers.
  4. Register. This identifier becomes your :apple_pass_type_id config and is embedded in every .pkpass you sign.

2. Generate a Certificate Signing Request (CSR)

On macOS, open Keychain Access → menu Keychain AccessCertificate AssistantRequest a Certificate From a Certificate Authority. Enter your email and a common name, select Saved to disk and Let me specify key pair information, then continue. Choose 2048-bit RSA. Save the .certSigningRequest file.

This step also creates a private key in your Keychain — keep it. You'll export it shortly.

3. Create the pass-type certificate

Back in the Apple Developer portal:

  1. Identifiers → click the pass type ID you just registered.
  2. Under Production Certificates, click Create Certificate.
  3. Upload the .certSigningRequest from step 2.
  4. Download the resulting pass.cer file.

Double-click pass.cer to import it into Keychain Access. Find the entry (it will say "Apple Pass Type ID: pass.com.example.mypass"), expand it to reveal the matching private key, and select both rows.

4. Export the cert and key as PEM

In Keychain Access, right-click the selected rows → Export 2 items → save as pass.p12. Set a password (you'll need it once). Then convert to PEM with openssl:

# Extract the certificate
openssl pkcs12 -in pass.p12 -clcerts -nokeys -out pass-cert.pem -legacy
# Extract the private key, unencrypted
openssl pkcs12 -in pass.p12 -nocerts -nodes -out pass-key.pem -legacy

The -legacy flag is needed for OpenSSL 3.x to read the older PKCS#12 format Keychain produces.

5. Download the WWDR intermediate

Apple publishes the WWDR (Apple Worldwide Developer Relations) intermediate certificate at https://www.apple.com/certificateauthority/. Download Worldwide Developer Relations - G4 (Expiring 12/10/2030 23:43:24 UTC) (or the current G-series equivalent) and convert it to PEM:

openssl x509 -in AppleWWDRCAG4.cer -inform DER -out wwdr.pem -outform PEM

You now have three files: pass-cert.pem, pass-key.pem, wwdr.pem.

Accepted formats

The three config keys (:apple_pass_type_cert, :apple_pass_type_key, :apple_wwdr_cert) each accept three formats:

# 1. A file path that exists on disk
config :wallet_passes, apple_pass_type_cert: "/etc/wallet/pass-cert.pem"

# 2. A literal PEM string (starts with "-----BEGIN")
config :wallet_passes,
  apple_pass_type_cert: """
  -----BEGIN CERTIFICATE-----
  MIIDoTCCAomgAwIBAgI...
  -----END CERTIFICATE-----
  """

# 3. A base64-encoded PEM (useful for env vars on platforms that strip newlines)
config :wallet_passes,
  apple_pass_type_cert: System.get_env("APPLE_PASS_TYPE_CERT_B64")

The library detects which format you've supplied by checking, in order: does the value exist as a file, does it start with -----BEGIN, and finally treats it as base64. For deployments, the base64 form is usually easiest: base64 -i pass-cert.pem | pbcopy and paste into a secret manager.

Google Credentials

You need one thing: a service account JSON key, with the Google Wallet API enabled on its project and the service account's email granted issuer access.

1. Create or pick a Google Cloud project

In the Google Cloud Console, create or select a project. Note the project ID.

2. Enable the Google Wallet API

Navigate to APIs & ServicesLibrary → search for "Google Wallet API" → Enable. Wait until the dashboard shows it as enabled.

3. Create a service account

IAM & AdminService AccountsCreate Service Account.

  • Name: anything (e.g. wallet-passes-issuer).
  • Service account ID: leave the auto-generated form.
  • Skip the optional role grant — Wallet API access is granted separately in the Wallet console (next step).

After creation, click the new service account → KeysAdd KeyCreate new keyJSON. A .json file downloads. Store it securely; the private key inside is not recoverable.

4. Grant the service account Wallet issuer access

In the Google Pay & Wallet Console, go to Google Wallet APIUsers → invite the service account's email (the client_email field inside the JSON) with the role Developer or Admin. Without this step, every API call will return 403.

Also note your Issuer ID (visible in the same console) — it's a ~16-digit number that becomes your :google_issuer_id.

Where to paste the JSON

Same three-format rule as Apple credentials:

# 1. File path
config :wallet_passes,
  google_service_account_json: "/etc/wallet/service-account.json"

# 2. Raw JSON string (works for env vars in JSON-safe systems)
config :wallet_passes,
  google_service_account_json: System.get_env("GOOGLE_WALLET_SERVICE_ACCOUNT_JSON")

# 3. The same JSON inlined directly (useful for local dev)
config :wallet_passes,
  google_service_account_json: ~s|{"type":"service_account","client_email":"..."}|

The library reads the value, tests if it's a path that exists, and otherwise parses it as JSON directly. Only two fields are actually used: client_email (the JWT issuer) and private_key (the RS256 signing key). Everything else can stay in the JSON; it's ignored.

Configuration

The library splits config across two files by convention:

  • config/config.exs — values knowable at compile time (your Repo module, your provider module, your pass type ID).
  • config/runtime.exs — secrets and per-environment values that come from environment variables.

Nothing forces this split; it's just the safest pattern for Phoenix releases (secrets never end up in compiled BEAM files).

config/config.exs — required and optional keys

import Config

config :wallet_passes,
  # REQUIRED. The Ecto.Repo module the library uses for all DB operations.
  repo: MyApp.Repo,

  # REQUIRED. A module implementing WalletPasses.PassDataProvider. The library
  # calls this when it needs to autonomously build pass content (e.g. when
  # Apple's webServiceURL asks for an updated pass).
  pass_data_provider: MyApp.WalletPassProvider,

  # REQUIRED for Apple. Your pass-type identifier, exactly as registered in
  # the Apple Developer portal. Must start with "pass.".
  apple_pass_type_id: "pass.com.example.mypass",

  # OPTIONAL. The HTTPS URL where you've mounted WalletPasses.Apple.Router.
  # When set, this URL is embedded into pass.json so Apple devices know
  # where to call for registration and updates. Omit during local dev if
  # you don't have a tunnel set up — passes still build, just without the
  # update lifecycle.
  apple_web_service_url: "https://yourdomain.com/passes/apple",

  # OPTIONAL. The HTTPS URL where you've mounted WalletPasses.Google.Router.
  # When set, the library writes "callbackOptions" onto every Google class
  # so Google's servers POST save/delete events to you. Without this,
  # wallet_presence/1 will always report :google as nil.
  google_callback_url: "https://yourdomain.com/passes/google/callback",

  # OPTIONAL. A module implementing WalletPasses.EventHandler. Lifecycle
  # events (on_pass_added, on_pass_removed, on_pass_fetched) dispatch here
  # asynchronously. Without this, events fire but are silently ignored.
  event_handler: MyApp.WalletEventHandler

config/runtime.exs — secrets

import Config

config :wallet_passes,
  # REQUIRED. Your Apple Developer team ID (10 chars). Find it in the
  # Apple Developer portal → Membership.
  apple_team_id: System.get_env("APPLE_TEAM_ID"),

  # REQUIRED. PEM-encoded pass-type certificate. Accepts a file path, a
  # raw PEM string, or a base64-encoded PEM.
  apple_pass_type_cert: System.get_env("APPLE_PASS_TYPE_CERT"),

  # REQUIRED. PEM-encoded private key matching the pass-type cert. Same
  # three-format rule.
  apple_pass_type_key: System.get_env("APPLE_PASS_TYPE_KEY"),

  # REQUIRED. PEM-encoded Apple WWDR intermediate certificate. Same
  # three-format rule.
  apple_wwdr_cert: System.get_env("APPLE_WWDR_CERT"),

  # REQUIRED. Your Google Wallet issuer ID (~16 digits) from the Google Pay
  # & Wallet Console.
  google_issuer_id: System.get_env("GOOGLE_WALLET_ISSUER_ID"),

  # REQUIRED. Service account JSON. Accepts a file path or a raw JSON string.
  google_service_account_json: System.get_env("GOOGLE_WALLET_SERVICE_ACCOUNT_JSON")

Advanced / rarely-set keys

These default to sensible production values; override them only for testing or specialised setups.

config :wallet_passes,
  # Used by Apple.Push to send APNs notifications. Default points at Apple's
  # production push server. Override in tests with a bypass URL.
  apple_push_base_url: "https://api.push.apple.com:443",

  # Base URL for the Google Wallet REST API. Override in tests with a bypass.
  google_api_base_url: "https://walletobjects.googleapis.com/walletobjects/v1",

  # Endpoint for OAuth token exchange. Rarely changed.
  google_token_url: "https://oauth2.googleapis.com/token",

  # URL where the library fetches Google's ECv2SigningOnly public keys for
  # callback verification. Default is the official endpoint.
  google_keys_url: "https://pay.google.com/gp/m/issuer/keys"

See the Local Development guide for the test-rig pattern that uses these overrides.

Database Setup

The library persists pass records in your Postgres database via Ecto. Migrations are generated by a mix task.

Generate the migrations

From your application root:

mix wallet_passes.gen.migration

This emits seven migration files under priv/repo/migrations/, with timestamps offset by 1 second each so they apply in order:

File suffixCreates
create_wallet_passes_apple.exswallet_passes_apple — one row per Apple pass
create_wallet_passes_google.exswallet_passes_google — one row per Google pass
create_wallet_pass_device_registrations.exswallet_pass_device_registrations — Apple devices
create_wallet_passes_google_callbacks.exswallet_passes_google_callbacks — Google audit log
add_wallet_passes_indexes.exsIndexes + foreign keys
validate_wallet_passes_foreign_keys.exsValidates the deferred FK constraints
add_wallet_passes_lifecycle.exsstatus + pass_type columns for lifecycle

What each table holds

  • wallet_passes_appleserial_number, auth_token (random per-pass token Apple devices send with every authenticated callback), status (active / voided / expired / completed), timestamps. One row per unique pass.

  • wallet_passes_googleserial_number, object_id (the full Google object identifier, <issuer_id>.<serial>), status, pass_type (cached for lifecycle transitions so they don't need to call the provider), timestamps.

  • wallet_pass_device_registrationsapple_pass_id foreign key, device_library_id (Apple's per-device identifier), push_token (APNs token for silent pushes). One row per (pass, device) pair. Updated whenever an iPhone (re)registers; deleted when the device unregisters.

  • wallet_passes_google_callbacksgoogle_pass_id foreign key, event_type (save or del), object_id, class_id, nonce (used to dedupe Google's at-least-once delivery), exp_time_millis, received_at. Append-only audit log of every Google callback. The library queries the latest row for wallet_presence/1.

Run the migrations

mix ecto.migrate

You can re-run mix wallet_passes.gen.migration after future library upgrades — it only emits migrations whose timestamp doesn't already exist in your priv/repo/migrations/ folder, so existing tables are never touched.

The PassDataProvider Behaviour

PassDataProvider is the consumer-side hook. The library needs to look up pass content autonomously — for example, when an iPhone calls your webServiceURL to fetch a refreshed pass after a silent push. Your provider answers the question: "Given this serial number, what should the pass look like?"

When the library calls it

  • Apple Web Service ProtocolGET /passes/<passTypeID>/<serial> hits your provider, then rebuilds and re-signs the .pkpass.
  • Lifecycle transitionsvoid_pass/1, expire_pass/1, etc. call the provider to resolve a missing pass_type for the Google object.
  • Oban Sync Worker (optional add-on) — calls the provider when bulk-syncing every active pass.

Your own code calling WalletPasses.build_apple_pass/3 and WalletPasses.google_save_url/3 directly does not go through the provider — you pass the PassData and visuals as arguments. The provider exists for the cases where the library needs to materialise a pass without you being in the call stack.

The contract

@callback build_pass_data(serial_number :: String.t()) ::
            {:ok, %{
               pass_data: WalletPasses.PassData.t(),
               apple: WalletPasses.Apple.Visual.t() | nil,
               google: WalletPasses.Google.Visual.t() | nil
             }}
            | {:error, term()}

Return {:error, :not_found} (or any error term) when the serial doesn't correspond to a real pass — the Apple router will turn it into a 404 for the requesting device.

Minimal implementation

For a smoke test, this is enough:

defmodule MyApp.WalletPassProvider do
  @behaviour WalletPasses.PassDataProvider

  @impl true
  def build_pass_data(serial_number) do
    {:ok,
     %{
       pass_data: %WalletPasses.PassData{
         serial_number: serial_number,
         pass_type: :event_ticket,
         description: "Demo Pass",
         organization_name: "Demo Co",
         primary_fields: [{"name", "Holder", "Smoke Test"}]
       },
       apple: %WalletPasses.Apple.Visual{
         background_color: "#1A1A1A",
         foreground_color: "#FFFFFF",
         icon_path: "priv/static/passes/icon.png"
       },
       google: %WalletPasses.Google.Visual{
         background_color: "#1A1A1A",
         logo_uri: "https://example.com/logo.png"
       }
     }}
  end
end

This will produce the same generic pass for every serial — fine for "does my cert chain work?" but not useful in production.

Realistic implementation

In real code, the provider looks up your own domain record (an order, a ticket, a membership) and projects it into a PassData. The library's lifecycle status should also feed back into the pass so devices see "VOIDED" on the back of a refunded ticket.

defmodule MyApp.WalletPassProvider do
  @behaviour WalletPasses.PassDataProvider

  alias MyApp.Tickets
  alias WalletPasses.{Apple, Google, PassData, PassDataProvider, Schema}

  @impl true
  def build_pass_data(serial_number) do
    case Tickets.get_by_serial(serial_number) do
      nil ->
        {:error, :not_found}

      ticket ->
        pass_data =
          %PassData{
            serial_number: serial_number,
            pass_type: :event_ticket,
            description: "#{ticket.event.name} ticket",
            organization_name: ticket.organizer.name,
            event_name: ticket.event.name,
            holder_name: ticket.holder_name,
            start_date: ticket.event.starts_at |> DateTime.to_date(),
            timezone: ticket.event.timezone,
            location_name: ticket.event.venue,
            primary_fields: [{"event", "Event", ticket.event.name}],
            secondary_fields: [
              {"section", "Section", ticket.section},
              {"row", "Row", ticket.row}
            ],
            auxiliary_fields: [
              {"seat", "Seat", ticket.seat},
              {"gate", "Gate", ticket.gate}
            ],
            back_fields: [
              {"terms", "Terms", "Non-refundable. See terms at example.com."}
            ],
            barcode_message: ticket.barcode,
            barcode_alt_text: ticket.barcode
          }
          # Decorate with current lifecycle status — adds a "STATUS" row to
          # back_fields for non-:active passes so devices see why a pass
          # is no longer valid.
          |> PassDataProvider.apply_status_decoration(Schema.get_pass_status(serial_number))

        {:ok,
         %{
           pass_data: pass_data,
           apple: %Apple.Visual{
             background_color: "#1A1A1A",
             foreground_color: "#FFFFFF",
             label_color: "#D4A843",
             logo_text: ticket.event.name,
             icon_path: "priv/static/passes/icon.png",
             strip_image_path: "priv/static/passes/strip.png"
           },
           google: %Google.Visual{
             background_color: "#1A1A1A",
             logo_uri: "https://cdn.example.com/logo.png",
             hero_image_uri: "https://cdn.example.com/hero.png"
           }
         }}
    end
  end
end

Two things to note:

  1. apply_status_decoration/2 prepends a {"status", "Status", "VOIDED"} row to back_fields for non-active passes. It's opt-in — if you'd rather render status differently (e.g. as a colour change), skip the call and inspect Schema.get_pass_status/1 yourself.
  2. The same provider serves Apple and Google. The library calls it once per lookup and uses whichever sub-struct (:apple or :google) the request needs. Either may be nil if you don't support that platform for the given serial.

End-to-End Walkthrough

This walkthrough produces a working .pkpass and a Save to Google Wallet URL from a fresh app. It assumes you've completed every section above: credentials obtained, config set, migrations run, provider implemented.

Project layout

lib/my_app/
  wallet_pass_provider.ex     # the PassDataProvider implementation
priv/static/passes/
  icon.png                    # 29x29 (also @2x: 58x58, @3x: 87x87)
  strip.png                   # 320x84 (also @2x: 640x168, @3x: 960x252)

The icon is mandatory for Apple — passes without one fail signature verification at install time. See the Theming & Visual Design guide for the full image dimensions table.

1. Mount the routers

In your Phoenix router.ex, mount both routers outside any CSRF-protected pipeline (neither sends a CSRF token):

defmodule MyAppWeb.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, ["json"]
  end

  scope "/" do
    pipe_through :api

    forward "/passes/apple", WalletPasses.Apple.Router
    forward "/passes/google", WalletPasses.Google.Router
  end
end

This makes https://yourdomain.com/passes/apple/... the Apple Web Service Protocol endpoint and https://yourdomain.com/passes/google/callback the target for Google's save/delete callbacks. Both URLs must match what you configured for :apple_web_service_url and :google_callback_url.

2. Build an Apple pass

Anywhere in your app — a controller, a Mix task, an iex session — call:

alias WalletPasses.{Apple, PassData}

pass_data = %PassData{
  serial_number: "demo-001",
  pass_type: :event_ticket,
  description: "Demo Ticket",
  organization_name: "Demo Co",
  event_name: "Demo Event",
  primary_fields: [{"event", "Event", "Demo Event"}],
  secondary_fields: [{"date", "Date", "May 17, 2026"}],
  barcode_message: "demo-001",
  barcode_alt_text: "demo-001"
}

apple_visual = %Apple.Visual{
  background_color: "#1A1A1A",
  foreground_color: "#FFFFFF",
  label_color: "#D4A843",
  logo_text: "Demo Co",
  icon_path: "priv/static/passes/icon.png",
  strip_image_path: "priv/static/passes/strip.png"
}

{:ok, pkpass_binary} = WalletPasses.build_apple_pass(pass_data, apple_visual)
File.write!("demo-001.pkpass", pkpass_binary)

That's a real, signed .pkpass. AirDrop it to your iPhone or email it to yourself and tap to add to Wallet.

What happened under the hood:

  1. WalletPasses.build_apple_pass/3 called Schema.get_or_create_apple_pass(serial_number) — INSERTed a row into wallet_passes_apple with a freshly-generated auth_token.
  2. Apple.Builder.build_pkpass/4 built the pass.json, read your image files, generated SHA1 hashes for everything into manifest.json, signed the manifest with PKCS#7 using your three PEM files, and zipped the lot together.
  3. The .pkpass binary returned. The DB row stays so future calls reuse the same auth_token.

3. Get a Google save URL

alias WalletPasses.Google

google_visual = %Google.Visual{
  background_color: "#1A1A1A",
  logo_uri: "https://yourcdn.example.com/logo.png",
  hero_image_uri: "https://yourcdn.example.com/hero.png"
}

{:ok, save_url} =
  WalletPasses.google_save_url(pass_data, google_visual,
    class_config: %{
      id: "demo_event_class",
      issuer_name: "Demo Co",
      event_name: "Demo Event"
    }
  )

Print or render that URL in a <a href="..."> somewhere. Tapping it on a phone with Google Wallet installed prompts the user to save the pass.

What happened:

  1. :class_config is set, so the library called Google.Api.ensure_class/2 — first lookup ran a GET against the Wallet API for the class ID; the 404 triggered a POST to create it. Subsequent calls for the same class are skipped (cached per VM lifetime).
  2. Schema.get_or_create_google_pass/2 INSERTed a row into wallet_passes_google.
  3. Google.Api.create_object/3 POSTed the pass object to Google's API, which returned the full object_id (e.g. 1234567890.demo-001).
  4. The library wrote the object_id back to the DB row.
  5. Google.SaveUrl.url/2 built the pass JSON, wrapped it in a JWT signed with your service account's private key, and returned the https://pay.google.com/gp/v/save/<jwt> URL.

4. Verify in the database

WalletPasses.Schema.get_apple_pass("demo-001")
# %WalletPasses.Schema.ApplePass{serial_number: "demo-001", auth_token: "...", status: "active", ...}

WalletPasses.Schema.get_google_pass("demo-001")
# %WalletPasses.Schema.GooglePass{serial_number: "demo-001", object_id: "1234567890.demo-001", ...}

If you install the pass on an iPhone and your :apple_web_service_url is reachable, you'll also see a row in wallet_pass_device_registrations. If you tap the save URL on an Android phone, you'll eventually see a save row in wallet_passes_google_callbacks.

5. Push an update

After changing pass content (e.g. the user upgraded their seat), call:

# Apple: tells every registered device for this serial to refetch.
WalletPasses.notify_apple_devices("demo-001")

# Google: PATCHes the object on Google's servers. The next time the
# user's phone syncs, the new content appears.
WalletPasses.update_google_pass(updated_pass_data, google_visual)

notify_apple_devices/1 sends silent APNs pushes to every device in wallet_pass_device_registrations for this serial. The devices' next fetch will go through your provider's build_pass_data/1, so make sure your provider returns the new content by the time the push fires.

See Pass Lifecycle & Updates for the full update model including void/expire/complete transitions.

What's Next

You have a working pass on both platforms. The other guides drill into specific areas:

  • Apple Wallet — the .pkpass bundle internals, the Web Service Protocol routes, APNs push semantics, image variants.
  • Google Wallet — class vs object model, save URL JWT internals, ECv2SigningOnly callback verification, class auto-creation.
  • Pass Lifecycle & Updates — void, expire, complete, reactivate, and the per-platform delivery semantics.
  • Event Handling & Wallet Presence — react to passes being added or removed from a wallet.
  • Localization — ship one pass that displays in multiple languages.
  • Pass Types — event ticket, boarding pass, loyalty, generic, coupon; which fields apply where.
  • NFC & Smart Tap — Apple VAS and Google Smart Tap setup.
  • Theming & Visual Design — the Theme helper, image dimensions, colour spaces.
  • Telemetry — every event the library emits, with measurements and metadata.
  • Add-ons — LiveView preview components and the Oban background sync worker.
  • Local Development — the bundled dev sandbox, test patterns with bypass, stub providers.

Troubleshooting

"Missing required config" exception at boot

The library calls raise for any required key returning nil. The message tells you which key — check both config/config.exs and config/runtime.exs, and remember that env-var-backed runtime values return nil until the env var is set in that shell/release.

"no_signing_credentials" from build_apple_pass/3

One of the three Apple PEMs failed to load. Common causes:

  • The file path doesn't exist (typo, or running from the wrong working directory).
  • A PEM string is missing its -----BEGIN/-----END lines (env-var systems sometimes strip whitespace).
  • A base64-encoded value isn't valid base64 (extra newlines or quotes).

Try WalletPasses.Apple.Builder.load_pem(your_config_value) in iex — it returns {:ok, pem_binary} or {:error, reason}.

Pass installs but iPhone says "Cannot Install"

Three likely causes, in order:

  1. Cert/key mismatch — the PEM cert and PEM key aren't a pair. Verify with openssl x509 -in pass-cert.pem -modulus -noout vs openssl rsa -in pass-key.pem -modulus -noout — the moduli must match.
  2. Missing WWDR — the WWDR cert is expired or wrong. Re-download from apple.com/certificateauthority.
  3. Pass type ID mismatch:apple_pass_type_id doesn't match the identifier the cert was issued for. Apple binds the cert tightly to one identifier.

403 from Google Wallet API

The service account email isn't authorised on your issuer account in the Google Pay & Wallet Console. Open the JSON, copy the client_email, re-invite it as a Developer in the console.

401 from Google Wallet API

The service account JSON is malformed or the private key inside is no longer valid. Regenerate the key from the IAM console (Keys → Add key → JSON) and update the config.

wallet_presence/1 always returns google: nil

Either no callbacks have arrived yet (the user hasn't saved/deleted) or :google_callback_url isn't configured. Without that config key, the library never writes callbackOptions to the class, so Google's servers have nowhere to POST. Set it and rerun Google.Api.ensure_class/2 (or simply call google_save_url/3 again with class_configensure_class is idempotent).

mix wallet_passes.gen.migration emits nothing

The task always emits all seven files; nothing is skipped automatically. If you don't see them, check:

  • Is your repo discoverable? The task uses Mix.Ecto.parse_repo/1 which looks at config :my_app, ecto_repos: [...].
  • Did the task error silently? Run with mix wallet_passes.gen.migration --repo MyApp.Repo to be explicit.

If a migration with the same filename already exists, Mix asks before overwriting. Re-running after an upgrade is generally safe — existing migrations have older timestamps, so they're skipped on mix ecto.migrate.

API Reference Summary

The entry points used in this guide:

Every function above is documented inline; h WalletPasses.build_apple_pass in iex shows the docstring with full options.