Skip to main content

guides/getting-started.md

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