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
PassDatastruct plus a per-platformApple.VisualorGoogle.Visual. - The library turns those structs into a signed
.pkpassbinary 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 publishedECv2SigningOnlykeys, and pushes APNs notifications when content changes. - You mount two Plug routers (
Apple.Router,Google.Router) somewhere in your Phoenix app, implement thePassDataProviderbehaviour, and the lifecycle works end-to-end.
Prerequisites
Before configuring the library you need:
- An Apple Developer Program membership ($99/year) — for the pass-type certificate.
- A Google Cloud project with the Google Wallet API enabled and a service account whose JSON key is downloadable.
- A Postgres database reachable from your app — the library persists pass records via Ecto.
- 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:
- Go to Certificates, Identifiers & Profiles → Identifiers.
- Click + → choose Pass Type IDs → Continue.
- Enter a description (e.g. "My App Loyalty Card") and a reverse-DNS
identifier (e.g.
pass.com.example.mypass). The identifier must start withpass.and must be globally unique across all Apple developers. - Register. This identifier becomes your
:apple_pass_type_idconfig and is embedded in every.pkpassyou sign.
2. Generate a Certificate Signing Request (CSR)
On macOS, open Keychain Access → menu Keychain Access →
Certificate Assistant → Request 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:
- Identifiers → click the pass type ID you just registered.
- Under Production Certificates, click Create Certificate.
- Upload the
.certSigningRequestfrom step 2. - Download the resulting
pass.cerfile.
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 & Services → Library → search for "Google Wallet API" → Enable. Wait until the dashboard shows it as enabled.
3. Create a service account
IAM & Admin → Service Accounts → Create 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 → Keys → Add Key →
Create new key → JSON. 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 API → Users → 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.WalletEventHandlerconfig/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 suffix | Creates |
|---|---|
create_wallet_passes_apple.exs | wallet_passes_apple — one row per Apple pass |
create_wallet_passes_google.exs | wallet_passes_google — one row per Google pass |
create_wallet_pass_device_registrations.exs | wallet_pass_device_registrations — Apple devices |
create_wallet_passes_google_callbacks.exs | wallet_passes_google_callbacks — Google audit log |
add_wallet_passes_indexes.exs | Indexes + foreign keys |
validate_wallet_passes_foreign_keys.exs | Validates the deferred FK constraints |
add_wallet_passes_lifecycle.exs | status + pass_type columns for lifecycle |
What each table holds
wallet_passes_apple—serial_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_google—serial_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_registrations—apple_pass_idforeign 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_callbacks—google_pass_idforeign key,event_type(saveordel),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 forwallet_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 Protocol —
GET /passes/<passTypeID>/<serial>hits your provider, then rebuilds and re-signs the.pkpass. - Lifecycle transitions —
void_pass/1,expire_pass/1, etc. call the provider to resolve a missingpass_typefor 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
endThis 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
endTwo things to note:
apply_status_decoration/2prepends a{"status", "Status", "VOIDED"}row toback_fieldsfor non-active passes. It's opt-in — if you'd rather render status differently (e.g. as a colour change), skip the call and inspectSchema.get_pass_status/1yourself.- The same provider serves Apple and Google. The library calls it once
per lookup and uses whichever sub-struct (
:appleor:google) the request needs. Either may benilif 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
endThis 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:
WalletPasses.build_apple_pass/3calledSchema.get_or_create_apple_pass(serial_number)— INSERTed a row intowallet_passes_applewith a freshly-generatedauth_token.Apple.Builder.build_pkpass/4built thepass.json, read your image files, generated SHA1 hashes for everything intomanifest.json, signed the manifest with PKCS#7 using your three PEM files, and zipped the lot together.- The
.pkpassbinary returned. The DB row stays so future calls reuse the sameauth_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:
:class_configis set, so the library calledGoogle.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).Schema.get_or_create_google_pass/2INSERTed a row intowallet_passes_google.Google.Api.create_object/3POSTed the pass object to Google's API, which returned the fullobject_id(e.g.1234567890.demo-001).- The library wrote the
object_idback to the DB row. Google.SaveUrl.url/2built the pass JSON, wrapped it in a JWT signed with your service account's private key, and returned thehttps://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
.pkpassbundle internals, the Web Service Protocol routes, APNs push semantics, image variants. - Google Wallet — class vs object model, save URL JWT
internals,
ECv2SigningOnlycallback 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
Themehelper, 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/-----ENDlines (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:
- Cert/key mismatch — the PEM cert and PEM key aren't a pair. Verify
with
openssl x509 -in pass-cert.pem -modulus -nooutvsopenssl rsa -in pass-key.pem -modulus -noout— the moduli must match. - Missing WWDR — the WWDR cert is expired or wrong. Re-download from apple.com/certificateauthority.
- Pass type ID mismatch —
:apple_pass_type_iddoesn'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_config — ensure_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/1which looks atconfig :my_app, ecto_repos: [...]. - Did the task error silently? Run with
mix wallet_passes.gen.migration --repo MyApp.Repoto 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:
WalletPasses.build_apple_pass/3— builds a signed.pkpassbinary.WalletPasses.google_save_url/3— returns a "Save to Google Wallet" URL.WalletPasses.notify_apple_devices/1— silent APNs push for a serial.WalletPasses.update_google_pass/3— PATCH a Google object's content.WalletPasses.wallet_presence/1—%{apple: boolean(), google: boolean() | nil}.WalletPasses.PassDataProvider— behaviour, single callbackbuild_pass_data/1.WalletPasses.PassDataProvider.apply_status_decoration/2— status row helper.WalletPasses.Schema.get_apple_pass/1,WalletPasses.Schema.get_google_pass/1,WalletPasses.Schema.get_pass_status/1— direct DB reads.
Every function above is documented inline; h WalletPasses.build_apple_pass
in iex shows the docstring with full options.