# Getting Started
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](https://developer.apple.com/account):
1. Go to **Certificates, Identifiers & Profiles** → **Identifiers**.
2. Click **+** → choose **Pass Type IDs** → **Continue**.
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 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:
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`:
```bash
# 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/](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:
```bash
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**:
```elixir
# 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](https://console.cloud.google.com/), 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](https://pay.google.com/business/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:
```elixir
# 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
```elixir
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
```elixir
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.
```elixir
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](local-development.md) 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:
```bash
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_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_callbacks`** — `google_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
```bash
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 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
```elixir
@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:
```elixir
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.
```elixir
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](theming.md)
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):
```elixir
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:
```elixir
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
```elixir
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
```elixir
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:
```elixir
# 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](lifecycle.md) 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](apple-wallet.md) — the `.pkpass` bundle internals, the
Web Service Protocol routes, APNs push semantics, image variants.
- [Google Wallet](google-wallet.md) — class vs object model, save URL JWT
internals, `ECv2SigningOnly` callback verification, class auto-creation.
- [Pass Lifecycle & Updates](lifecycle.md) — void, expire, complete,
reactivate, and the per-platform delivery semantics.
- [Event Handling & Wallet Presence](event-handling.md) — react to
passes being added or removed from a wallet.
- [Localization](localization.md) — ship one pass that displays in
multiple languages.
- [Pass Types](pass-types.md) — event ticket, boarding pass, loyalty,
generic, coupon; which fields apply where.
- [NFC & Smart Tap](nfc.md) — Apple VAS and Google Smart Tap setup.
- [Theming & Visual Design](theming.md) — the `Theme` helper, image
dimensions, colour spaces.
- [Telemetry](telemetry.md) — every event the library emits, with
measurements and metadata.
- [Add-ons](addons.md) — LiveView preview components and the Oban
background sync worker.
- [Local Development](local-development.md) — 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](https://www.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_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/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:
- `WalletPasses.build_apple_pass/3` — builds a signed `.pkpass` binary.
- `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 callback `build_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.