# Google Wallet
This guide covers everything `wallet_passes` does for Google Wallet:
generating "Save to Google Wallet" URLs, creating and updating pass objects
and classes via the Wallet REST API, mounting the signed save/delete
callback router, and the per-pass-type quirks you'll hit along the way.
If you're brand new to Google Wallet, start with the [Concepts](#concepts)
section — it explains the API's class/object split and how a JWT save URL
puts a pass onto a user's phone without any direct API call. If you're
already familiar with the Wallet API, jump straight to
[Using This Library](#using-this-library).
For credential setup (service account JSON, issuer ID, config keys), see the
[Getting Started](getting-started.md) guide.
## Overview
### What's covered
- **Save URLs** via `WalletPasses.google_save_url/3` — a signed JWT wrapped
in `https://pay.google.com/gp/v/save/...`. Hand this URL to the user as a
link or a "Save to Google Wallet" button.
- **Class + object lifecycle** via `WalletPasses.Google.Api` —
idempotent class creation (`ensure_class/2`), object create/update
(`create_object/3`, `update_object/4`), and state PATCH
(`update_object_state/3`).
- **OAuth2 token exchange** via `WalletPasses.TokenCache` — service-account
JWTs are signed with RS256, swapped for a one-hour access token, and
cached in ETS. You never touch the OAuth flow directly.
- **Save/delete callback verification** via `WalletPasses.Google.Router` —
a plug router mounted at any path, validating every callback against
Google's published `ECv2SigningOnly` root keys with no shared secret
and no Tink dependency.
- **Per-pass-type class field shaping** — the five supported pass types
(`:event_ticket`, `:boarding_pass`, `:store_card`, `:coupon`, `:generic`)
each have a different class title field (`eventName` vs `programName` vs
`title`); the library picks the right one from `pass_type`.
### What's not covered
- **Direct REST calls.** `WalletPasses.Google.Api` is a typed wrapper. If
you need a Wallet API surface this library doesn't expose (group
passes, transit-class extensions, the merchant center, etc.), call
Google's API yourself with the access token from
`WalletPasses.Google.Api.get_access_token/0`.
- **Service account creation.** Generating a service-account JSON and
granting it Wallet API access in Google Cloud Console is out of scope
for this guide — see [Getting Started](getting-started.md).
- **Visual rendering of the saved pass.** Google renders server-side from
your object JSON; this library hands Google the JSON. For pixel-level
styling, see [Theming & Visual Design](theming.md).
### Why Google's model differs from Apple's
Apple ships every pass as a self-contained signed `.pkpass` ZIP that the
device renders locally. Google stores every pass on its own servers and
re-renders on demand. So: there is no bundle to sign, the class concept
is Google-only, updates take effect immediately (no per-device push), and
localization is resolved server-side from your `translatedValues` array —
see [Localization](localization.md).
## Quick Start
Configure your service account and issuer ID (see
[Getting Started](getting-started.md) for the full walkthrough):
```elixir
# config/runtime.exs
config :wallet_passes,
google_issuer_id: System.get_env("GOOGLE_WALLET_ISSUER_ID"),
google_service_account_json: System.get_env("GOOGLE_WALLET_SERVICE_ACCOUNT_JSON"),
google_callback_url: "https://yourdomain.com/passes/google/callback"
```
Build a `PassData` and a `Google.Visual`, then ask for a save URL:
```elixir
alias WalletPasses.{Google, PassData}
pass_data = %PassData{
serial_number: "ticket-42",
pass_type: :event_ticket,
description: "Summer Music Festival ticket",
organization_name: "Festival Co",
event_name: "Summer Music Festival",
holder_name: "Jane Doe",
primary_fields: [{"name", "Name", "Jane Doe"}],
secondary_fields: [
{"section", "Section", "A"},
{"row", "Row", "12"}
],
barcode_message: "ticket-42"
}
google_visual = %Google.Visual{
background_color: "#1A1A1A",
logo_uri: "https://example.com/logo.png",
hero_image_uri: "https://example.com/hero.png"
}
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
class_config: %{
auto_create: true,
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival",
location_name: "Riverside Park",
location_address: "100 River Rd, Asheville NC"
}
)
# save_url is a "https://pay.google.com/gp/v/save/<jwt>" link.
# Render it as a button in your UI:
# <a href={@save_url}>
# <img src="https://developers.google.com/wallet/.../enUS_add_to_google_wallet_add-wallet-badge.png">
# </a>
```
Mount the callback router so Google can tell you when users save or remove
the pass:
```elixir
# lib/my_app_web/router.ex
forward "/passes/google", WalletPasses.Google.Router
```
That's everything you need for a working integration. The next sections
explain what each piece does and how to customise it.
## Concepts
### Class vs object
Google Wallet has a strict two-level model:
- A **class** is the shared template for a kind of pass. It carries the
event name, issuer name, venue, branding, callback URL, Smart Tap
configuration — anything that's the same for every ticket. There is one
class per event (or per loyalty program, or per coupon promotion).
- An **object** is a single user's instance of that class. It carries the
serial number, the barcode payload, the ticket holder's name, lifecycle
state (`ACTIVE`/`INACTIVE`/`EXPIRED`/`COMPLETED`) — anything that's
unique per pass.
Every object holds a `classId`, and Google resolves the class lazily when
it renders the pass on a device. If you have 10,000 tickets to one event,
you create one class and ten thousand objects.
**Who owns updates:**
- Update the **class** when *all* tickets need to change: venue moved, event
was renamed, you added a Smart Tap redemption issuer, you turned on the
callback URL for the first time.
- Update the **object** when *one* ticket changes: holder name was
corrected, the seat got moved, the lifecycle state transitioned.
`WalletPasses.Google.Api.ensure_class/2` and the `:class_config`
auto-creation flow (covered below) handle classes idempotently for you.
The object is created on first `google_save_url/3` call and updated by
`update_google_pass/3` or one of the lifecycle helpers.
### JWT save URLs
You never call Google's REST API to "give a user a pass". You sign a JWT
that *describes* the object, wrap it in a URL, and hand the URL to the
user. When the user taps the URL on an Android device, Google Wallet
opens, verifies the JWT against your service account's public key, and
saves the pass.
A Save URL looks like:
```
https://pay.google.com/gp/v/save/<jwt>
```
The JWT payload follows this shape (decoded from the second segment of
the JWT):
```json
{
"iss": "wallet-service@my-project.iam.gserviceaccount.com",
"aud": "google",
"typ": "savetowallet",
"iat": 1715980800,
"origins": ["https://my-site.com"],
"payload": {
"eventTicketObjects": [
{
"id": "3388000000022000000.ticket-42",
"classId": "3388000000022000000.summer_2026",
"state": "ACTIVE",
"barcode": {"type": "QR_CODE", "value": "ticket-42"},
"ticketHolderName": "Jane Doe",
...
}
]
}
}
```
The JWT is signed with RS256 using your service account's private key, so
Google can verify it without any pre-shared secret on the request itself.
The `payload` carries the same object shape that the REST API expects —
in fact, the library writes the same object to the API *and* signs a
copy into the JWT, so the user gets the same pass whether they save from
the URL or fetch the pass on a second device.
**`:origins` matters for web embeds.** If you render the Save button
inside a web page, Google validates that the page's URL is in the
`origins` list (or it refuses to save). Set `:origins` to the list of
domains you embed from. Skip it entirely (or pass `[]`) when the link is
shared directly — push notifications, email links, SMS — because there
is no embedding origin in those cases.
### Server-side rendering
Google stores the *data* on its servers and renders fresh every time a
device opens the pass:
- A pass update via the API takes effect the next time a device syncs
(usually minutes); no push required, no per-device tracking.
- Devices don't register with you — Google handles device tracking through
the user's Google account. There's no parallel of Apple's
`DeviceRegistration` table.
- Images live at the URIs you provide (`logo_uri`, `hero_image_uri`,
image module URIs). Google fetches and re-serves them, so host them
somewhere stable. Rotating an image URL means devices keep seeing the
cached version until Google's cache expires.
### ECv2SigningOnly callback signatures
When a user saves or deletes a pass, Google POSTs a signed envelope to
your `google_callback_url`. The signature scheme is called
**ECv2SigningOnly** — it's the Google Pay tokenization protocol stripped
to just the signing layer (no encryption, since save/delete metadata
isn't sensitive PII).
The envelope nests two layers of signatures:
```json
{
"protocolVersion": "ECv2SigningOnly",
"signedMessage": "{\"objectId\":\"3388....ticket-42\",\"eventType\":\"save\",\"nonce\":\"...\",\"expTimeMillis\":\"...\"}",
"signature": "MEUCIQ...",
"intermediateSigningKey": {
"signedKey": "{\"keyValue\":\"...\",\"keyExpiration\":\"1715980800000\"}",
"signatures": ["MEYCIQ..."]
}
}
```
To verify, the library:
1. Fetches Google's published **root** ECDSA P-256 public keys from
`https://pay.google.com/gp/m/issuer/keys` (cached in ETS for ~55
minutes).
2. Verifies `intermediateSigningKey.signatures` against the roots — this
proves the intermediate key was issued by Google.
3. Decodes the intermediate `keyValue` (a base64-encoded P-256 public
key) and verifies `signature` against `signedMessage` using it.
4. Decodes `signedMessage` as JSON and pulls out `objectId`, `eventType`,
and `nonce`.
The verification is **stateless** — there are no shared secrets, no
per-request signing keys, no HMAC, no replay token your server has to
issue. You configure exactly one thing (`google_issuer_id`, which goes
into the signature input as a constant tag), and the rest is public-key
crypto.
The implementation is in `WalletPasses.Google.CallbackVerifier`. It uses
`:public_key` and `:crypto` from OTP — no Tink, no NIFs, no shells out.
## Using This Library
### `WalletPasses.google_save_url/3` — the front door
The single-call helper. Internally it:
1. Optionally creates or updates the class on Google's servers
(idempotent — see `:class_config` below).
2. Looks up or creates a `wallet_passes_google` row for the serial.
3. POSTs the pass object to Google's API (or PUTs if the object already
exists — handles the 409).
4. Stores the resulting `object_id` on the DB row.
5. Builds the JWT and returns the save URL.
```elixir
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
class_config: %{
auto_create: true,
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival"
},
origins: ["https://my-site.com"],
translations: %{"fr" => %{"Gate" => "Porte"}}
)
```
Options:
- **`:class_id`** — class ID suffix. Defaults to the pass type's standard
suffix (`event_class`, `flight_class`, `loyalty_class`, `offer_class`,
`generic_class`). Use a custom suffix to scope a class to a specific
event or promotion (e.g. `"summer_2026"`).
- **`:class_config`** — map of class fields. When present, the library
calls `Google.Api.ensure_class/2` before creating the object so the
class exists.
- **`:origins`** — list of web origins allowed to embed the save button.
Pass `[]` or omit when sharing the link directly.
- **`:translations`** — `%{locale_tag => %{source => translated}}` for
object-level localizable text. See [Localization](localization.md). To
localize class-level fields, pass `:translations` *inside*
`class_config`.
The returned URL is shaped as
`https://pay.google.com/gp/v/save/<jwt>`. Use it as the `href` on a
`<a>` tag wrapping Google's "Save to Google Wallet" badge image.
### Class auto-creation with `:class_config`
The class must exist on Google's servers before any object that references
it. You have two ways to create it:
1. **Pass `class_config` to `google_save_url/3` (or `update_google_pass/3`).**
The library calls `Google.Api.ensure_class/2` first, which is a no-op
after the first successful call per VM lifetime per class.
2. **Call `WalletPasses.Google.Api.create_or_update_class/2` directly**
once at deploy time or in a migration. This gives you full control
over when classes change.
The `class_config` approach is the simplest. Every save URL request
checks an in-VM ETS flag for `{:class_ensured, class_id}` — on the first
request it PUTs the class (falling back to POST on 404), on every
subsequent request it skips the network call entirely. The class is
idempotently created from your config:
```elixir
class_config = %{
auto_create: true, # Documentation flag, doesn't affect behaviour;
# the presence of class_config triggers ensure_class.
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival",
pass_type: :event_ticket,
start_date: "2026-07-04T00:00:00Z",
end_date: "2026-07-06T23:59:59Z",
location_name: "Riverside Park",
location_address: "100 River Rd, Asheville NC",
latitude: 35.5951,
longitude: -82.5515,
logo_uri: "https://example.com/class-logo.png",
enable_smart_tap: false,
redemption_issuers: []
}
```
If `class_config` already references a class that exists with different
fields, the first call in the VM lifetime will PATCH it to match your
config (via PUT). After that, the ETS-cached "ensured" flag means
subsequent saves don't re-PUT. To force a class update mid-run, call
`WalletPasses.Google.Api.create_or_update_class/2` directly — it bypasses
the cache.
The `:id` key inside `class_config` is the class suffix (the part after
the issuer ID prefix). If omitted, the library falls back to the
`:class_id` opt or the pass type's standard suffix.
### Class fields per pass type
The class JSON shape changes by pass type. The library handles this
automatically based on `pass_type` (in `class_config[:pass_type]`,
defaulting to `:event_ticket`):
| `:pass_type` | Class title field | Object holder field | Object resource |
|------------------|-------------------|-----------------------|---------------------|
| `:event_ticket` | `eventName` | `ticketHolderName` | `eventTicketObject` |
| `:boarding_pass` | *(none)* | `passengerName` | `flightObject` |
| `:store_card` | `programName` | `accountName` | `loyaltyObject` |
| `:coupon` | `title` | *(omitted)* | `offerObject` |
| `:generic` | *(none)* | `header.defaultValue` | `genericObject` |
In `class_config`, you always pass `:event_name` as the user-facing class
title; the library writes it to the right field name. This matters
because Google rejects classes with the wrong field — a loyalty class
with `eventName` fails validation; an offer class needs `title`, not
`eventName`.
```elixir
# Loyalty class — programName is what you pass via :event_name
WalletPasses.google_save_url(loyalty_pass_data, visual,
class_config: %{
id: "rewards",
issuer_name: "Coffee Co",
event_name: "Coffee Rewards", # → written as programName
pass_type: :store_card
}
)
# Coupon class — title is what you pass via :event_name
WalletPasses.google_save_url(coupon_pass_data, visual,
class_config: %{
id: "summer_promo",
issuer_name: "Coffee Co",
event_name: "20% Off Summer Drinks", # → written as title
pass_type: :coupon
}
)
```
For event tickets, the localized siblings `localizedProgramName` and
`localizedTitle` are added when translations match. See
[Localization](localization.md).
For per-type field details, see the [Pass Types](pass-types.md) guide.
### `WalletPasses.update_google_pass/3` — change an existing object
To update the object content (fields, holder name, barcode, visual
elements) without changing its lifecycle state:
```elixir
{:ok, _object_id} =
WalletPasses.update_google_pass(updated_pass_data, google_visual,
class_config: %{...},
translations: translations
)
```
This PATCHes the existing object on Google's servers. Devices with the
pass saved pick up the change on their next sync (typically within
minutes — there's no push to send). If `class_config` is provided, the
class is ensured first.
Returns `{:error, :not_found}` if no `wallet_passes_google` row exists
for the serial, or `{:error, :no_object_id}` if a row exists but the
object hasn't been created yet (e.g. `google_save_url/3` failed
mid-flow).
### Lifecycle state transitions
`WalletPasses.void_pass/1` / `expire_pass/1` / `complete_pass/1` /
`reactivate_pass/1` update the object's `state` field via
`WalletPasses.Google.Api.update_object_state/3` and don't rebuild the
full object payload. State maps to:
- `:active` → `"ACTIVE"`
- `:voided` → `"INACTIVE"`
- `:expired` → `"EXPIRED"`
- `:completed` → `"COMPLETED"`
A pass with state `"INACTIVE"`, `"EXPIRED"`, or `"COMPLETED"` is visually
greyed out in Google Wallet but stays on the device. See
[Pass Lifecycle & Updates](lifecycle.md) for the full transition model
and the per-platform `lifecycle_result` shape.
### OAuth token cache
`WalletPasses.Google.Api.get_access_token/0` signs a JWT assertion with
your service account's RS256 key, exchanges it at
`https://oauth2.googleapis.com/token`, and caches the token in ETS for 55
minutes. You will rarely call it directly — every library function that
hits the Wallet API uses it internally. To force a refresh (e.g. after
rotating service-account keys): `WalletPasses.TokenCache.delete(:google_access_token)`.
### Mounting `Google.Router`
`WalletPasses.Google.Router` is a `Plug.Router` exposing `POST /callback`.
Mount it in your Phoenix router **outside any CSRF-protected pipeline**
(Google doesn't send a CSRF token):
```elixir
# lib/my_app_web/router.ex
forward "/passes/google", WalletPasses.Google.Router
```
The full callback URL is the mount path + `/callback` — e.g.
`https://yourdomain.com/passes/google/callback`. Configure it:
```elixir
config :wallet_passes,
google_callback_url: "https://yourdomain.com/passes/google/callback"
```
The URL is written into every class's `callbackOptions` field by
`build_class_object/1`. If `:google_callback_url` is unset,
`callbackOptions` is omitted and Google will not send callbacks — even
if you mount the router. (This is the right behaviour for local dev:
leave the URL unset and Google won't try to hit a tunnel that isn't
running.)
### What the router does with each request
For every `POST /callback`:
1. Verify the envelope with `CallbackVerifier.verify/2`. On failure, respond `401`.
2. Extract the serial from `signedMessage.objectId` (everything after the first `.`).
3. Look up the pass in `wallet_passes_google`. If unknown, respond `200` and ignore.
4. Insert a `wallet_passes_google_callbacks` row. The `(google_pass_id, nonce)`
unique index makes duplicate callbacks a no-op — Google retries on timeout,
and this is your replay protection.
5. Dispatch a `:pass_added` or `:pass_removed` event to your
`WalletPasses.EventHandler` asynchronously under `Task.Supervisor`.
6. Respond `200`.
Response codes: `200` for any verified envelope (known or unknown pass,
new or duplicate nonce); `401` for signature or protocol failure (Google
will retry); `404` for any non-`/callback` path. The router never returns
`500` — schema validation failures log a warning and return `200`.
### The audit table
Every successfully verified callback writes a row to
`wallet_passes_google_callbacks`:
| Column | Type | Notes |
|-------------------|---------------------|-------------------------------------------------|
| `id` | bigserial | |
| `google_pass_id` | bigint | FK to `wallet_passes_google` |
| `event_type` | string | `"save"` or `"del"` |
| `object_id` | string | full `<issuer>.<serial>` |
| `class_id` | string | full `<issuer>.<suffix>` |
| `nonce` | string | unique per `(google_pass_id, nonce)` |
| `exp_time_millis` | bigint nullable | Google's expiration timestamp on the signed msg |
| `received_at` | utc_datetime_usec | when the library inserted the row |
Query the history:
```elixir
alias WalletPasses.Schema
# Most recent event (or nil)
Schema.latest_google_callback("ticket-42")
#=> %WalletPasses.Schema.GoogleCallback{event_type: "save", ...}
# Full ordered history
Schema.google_callback_history("ticket-42")
#=> [%GoogleCallback{event_type: "save", ...},
# %GoogleCallback{event_type: "del", ...},
# %GoogleCallback{event_type: "save", ...}]
```
The audit table is also what `WalletPasses.wallet_presence/1` reads to
report whether a pass is currently saved on Google. The presence map's
`:google` key is `true` if the latest event is `save`, `false` if `del`,
and `nil` if no callback has ever been received.
The `nil` distinction matters: `false` means Google told us the pass was
removed, while `nil` means we have no information (either the pass was
never saved, or `:google_callback_url` isn't configured and callbacks
were never enabled). See [Event Handling & Wallet Presence](event-handling.md)
for more on `wallet_presence/1`.
### Direct API access
For workflows the high-level helpers don't cover, drop down to
`WalletPasses.Google.Api` — every Wallet operation the library performs is
exposed there as a public function (see the [API Reference](#api-reference)
below). Each emits `[:wallet_passes, :google, <op>, :start|:stop]` telemetry
events.
## Recipes
### Recipe 1: One-shot save URL with class auto-creation
The minimum-ceremony path. Use this when you have one event and one batch
of tickets:
```elixir
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
class_config: %{
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival",
location_name: "Riverside Park",
location_address: "100 River Rd, Asheville NC",
start_date: "2026-07-04T00:00:00Z",
end_date: "2026-07-06T23:59:59Z"
}
)
```
First call: the class is PUT to Google's servers, the object is POSTed,
the JWT is signed, the URL comes back. Second call (same VM, same
class): the class step is a no-op (ETS cache hit), the object is POSTed
or PATCHed (since it now exists), the JWT is signed, the URL comes back.
### Recipe 2: Pre-create the class at boot time
For higher-volume apps where you don't want each request to do the
class-existence check (even though it's cheap after the first), pre-create
classes in your `Application.start/2` or a release task:
```elixir
defmodule MyApp.WalletClasses do
alias WalletPasses.Google.Api
def ensure_all do
Api.create_or_update_class(%{
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival"
})
# repeat for every event you have...
end
end
```
Then call `google_save_url/3` without `class_config` — the object
references the class you already created:
```elixir
WalletPasses.google_save_url(pass_data, visual, class_id: "summer_2026")
```
This is also how you should handle class field *updates* — Google caches
class lookups aggressively on its own side, so re-running this at deploy
time after editing a class's fields is the way to push changes.
### Recipe 3: Loyalty card with Smart Tap
Loyalty / store-card passes can carry a Smart Tap NFC payload. You need
the partner approval flag from Google first (see [NFC & Smart Tap](nfc.md)),
then:
```elixir
pass_data = %PassData{
serial_number: "member-1234",
pass_type: :store_card,
description: "Coffee Co Rewards",
organization_name: "Coffee Co",
holder_name: "Jane Doe",
nfc_message: "REDEEM-MEMBER-1234", # → smartTapRedemptionValue
primary_fields: [{"points", "Points", "320"}]
}
google_visual = %Google.Visual{
background_color: "#3E2723",
logo_uri: "https://coffeeco.example.com/logo.png"
}
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
class_config: %{
id: "coffee_rewards",
issuer_name: "Coffee Co",
event_name: "Coffee Rewards",
pass_type: :store_card,
enable_smart_tap: true,
redemption_issuers: ["YOUR_REDEMPTION_ISSUER_ID"]
}
)
```
The `:pass_type` in `class_config` makes the class use the loyalty shape
(`programName` instead of `eventName`); `:pass_type` on `pass_data` makes
the object use `accountName` and `loyaltyObject` resource. Don't mix
them — set both to `:store_card`.
### Recipe 4: Reacting to a save callback
Implement the `WalletPasses.EventHandler` behaviour:
```elixir
defmodule MyApp.WalletEventHandler do
@behaviour WalletPasses.EventHandler
@impl true
def on_pass_added(serial, :google, _meta),
do: MyApp.Orders.mark_saved_to_google_wallet(serial)
@impl true
def on_pass_removed(serial, :google, _meta),
do: MyApp.Orders.mark_pass_removed(serial)
end
# config/config.exs
config :wallet_passes, event_handler: MyApp.WalletEventHandler
```
Google's `on_pass_removed` is authoritative — the user definitely removed
the pass. (Apple's is ambiguous; see [Event Handling](event-handling.md).)
The router dispatches under `Task.Supervisor`, so handlers can take any
amount of time without extending Google's request timeout.
### Recipe 5: Update an object without changing lifecycle
The user's name was misspelled. Fix it and PATCH the object — devices
pick up the change on their next sync:
```elixir
corrected = %{pass_data | holder_name: "Janelle Doe"}
{:ok, _object_id} = WalletPasses.update_google_pass(corrected, google_visual)
```
If you also changed class-level fields (e.g. the venue moved), pass
`class_config` and the class will be re-PUT on this call only if its
ETS-cached "ensured" flag has been cleared. To force a class refresh
regardless of the cache, call `Google.Api.create_or_update_class/2`
directly.
### Recipe 6: Building the JWT yourself
For full control over the object before signing, build the object map,
mutate it, then call `Google.SaveUrl.url/2`:
```elixir
alias WalletPasses.Google.{Api, SaveUrl}
pass_object =
Api.build_pass_object(pass_data, google_visual)
|> Map.put("linksModuleData", %{
"uris" => [%{"uri" => "https://example.com/help", "description" => "Help"}]
})
{:ok, save_url} = SaveUrl.url(pass_object, origins: ["https://my-site.com"])
```
`SaveUrl.url/2` only signs the JWT; it does not POST to Google's API. If
you also need the object to exist on Google's servers (so future updates
work), call `Api.create_object/3` separately.
## What's NOT Covered
- **Pure REST API mirroring.** `Google.Api` covers create/update for
classes and objects and PATCH for object state. If you need to
*delete* an object, list objects, batch updates, or use any of the
more obscure Wallet endpoints (`addmessage`, group passes, transit
agency extensions), call Google's REST API directly — use
`Api.get_access_token/0` to get the bearer token.
- **Custom JWT claims.** The save URL JWT has a fixed shape:
`iss`, `aud`, `typ`, `iat`, `origins`, `payload`. There's no hook to
add arbitrary claims. If you need a different JWT structure, build it
yourself with `Joken` and the service account key.
- **Multiple objects in one save URL.** Google supports JWTs that save
several passes at once (the `payload` is an array). This library
always wraps a single object. Build the JWT manually if you need
multi-pass save links.
- **Web-service pass updates.** Apple has a pull-based update mechanism;
Google doesn't. There's no "device pulls pass JSON" route to expose,
no `authenticationToken` on the object, no parallel of
`Apple.Router`'s registration endpoints.
- **Image hosting.** You provide HTTP(S) URIs for `logo_uri`,
`hero_image_uri`, and image module URIs. The library never uploads
images to Google or to any CDN. Host them yourself.
## Troubleshooting
### 400 from `create_object` / `create_or_update_class`
`{:error, {400, body}}` where `body` is a JSON map. Common causes:
- **Field is wrong shape for the pass type.** A loyalty class with
`eventName` will 400 — Google expects `programName`. Check `pass_type`
in `class_config` matches `pass_data.pass_type`.
- **Required field missing.** Loyalty classes need `issuerName` and
`programName`. Offers need `title`. Event tickets need `eventName`.
The library writes these from `class_config[:event_name]`, but if
you've omitted `:event_name` entirely the class will fail.
- **Image URI is not HTTPS or is unreachable.** Google validates image
URIs at class/object create time. Use HTTPS and host on a
publicly-reachable origin.
- **`locations[]` malformed.** `latitude` and `longitude` must both be
numbers. The library only emits `locations` when both are set, so an
accidental nil-or-string for one suppresses the whole array.
Inspect `body` directly — Google's error messages name the offending
field path.
### 403 from `create_object` / OAuth token exchange
Almost always a service-account permissions issue:
- The service account doesn't have **Wallet Object Issuer** role on
your issuer in the Google Wallet console. Add it at
`https://pay.google.com/business/console/...`.
- The service account JSON in your config is for a different project
than the issuer is registered under.
- The service account key was rotated and your env var is stale.
Re-check the steps in [Getting Started](getting-started.md) for the
service-account setup. Confirm the JSON's `client_email` is added as a
user on your issuer with Object Issuer access.
### `:no_google_credentials` error
`load_credentials/0` returns `{:error, :no_google_credentials}` when:
- `:google_service_account_json` is unset.
- The configured value is neither a path to an existing file nor a
valid JSON string.
- The value is a path but the file isn't readable (permissions).
The library accepts the env var as a file path *or* an inline JSON
string. Base64-encoded JSON is **not** accepted — decode it before
setting the env var.
### Signature verification fails on every callback
`POST /callback` returns 401 for every Google retry. Walk this list:
1. **Are you on `ECv2SigningOnly`?** The library only supports that
protocol version. Google never sends a different version for save/
delete callbacks, but a misconfigured proxy or test harness might
strip headers and replay a wrong-version body. Check
`signedMessage.protocolVersion` in the request body.
2. **Are root keys reachable?** The library fetches
`https://pay.google.com/gp/m/issuer/keys` on the first verification
per VM lifetime. If your egress firewall blocks it, every verification
fails. Set `:google_keys_url` in config to a mock for testing if you
need to.
3. **Is `:google_issuer_id` set and correct?** The issuer ID is part of
the signature input. A mismatch (typo, or environment variable
pointing at the wrong issuer) will make every signature look invalid.
4. **Is your tunnel intercepting the body?** Some local dev proxies
(`ngrok` HTTP rewriting, `localhost.run` with rewrites) modify the
POST body or headers. The verifier needs the body byte-for-byte. Use
a TCP-level tunnel.
### "User saved the pass but my callback never fired"
Three common causes:
1. **`:google_callback_url` is unset.** Without it,
`build_class_object/1` doesn't emit `callbackOptions`, and Google
doesn't know where to POST. Check
`Application.get_env(:wallet_passes, :google_callback_url)` at
runtime — it must be the *publicly reachable* URL Google will hit.
2. **The class was created before `:google_callback_url` was set.** The
`callbackOptions` is a class-level field, so adding the config after
the class exists doesn't retroactively enable callbacks. Re-call
`Google.Api.create_or_update_class/2` on the affected class, or
delete the ETS cache flag and let `ensure_class/2` re-PUT it.
3. **The URL isn't reachable from Google.** Your local
`https://localhost:4000/...` URL won't work — Google has to hit a
public IP. Use `ngrok` or similar in dev. In production, check that
your firewall allows inbound from Google's IP range to the callback
path, and that any reverse proxy in front of the app forwards to it
without rewriting the path.
To verify the class actually has `callbackOptions` set, fetch it
manually:
```elixir
alias WalletPasses.{Config, Google.Api}
{:ok, token} = Api.get_access_token()
Req.get!("#{Config.google_api_base_url()}/eventTicketClass/#{Config.google_issuer_id()}.summer_2026",
headers: [{"authorization", "Bearer #{token}"}]
).body
```
The response should include `"callbackOptions": {"url": "..."}`.
### Save URL works but the pass shows old class fields
Google caches class data on its side. Updating a class doesn't always
propagate to already-saved passes immediately — saved passes can show
the old class for up to a few hours after a class PATCH. To verify the
class actually changed on Google's side, fetch it as in the previous
section. If the class is updated on Google's servers but devices still
show old content, it's Google-side caching and there's no library-level
fix.
### `update_google_pass` returns `{:error, :no_object_id}` or `:not_found`
`:not_found` means no `wallet_passes_google` row exists for the serial
— call `google_save_url/3` first to create it. `:no_object_id` means the
row exists but the object POST to Google's API failed somewhere in the
middle of the save URL flow; retry the save URL flow and the row will
be populated.
### "Origins" complaint when embedding the save button
A "this domain is not authorized" error from Google means the page's
origin isn't in the JWT's `:origins`. Pass every embedding host:
```elixir
WalletPasses.google_save_url(pass_data, visual,
origins: ["https://app.example.com", "https://staging.example.com"]
)
```
For links shared via email or SMS, `:origins` isn't checked — omit it.
### Duplicate callback rows in `wallet_passes_google_callbacks`
The `(google_pass_id, nonce)` unique index normally prevents these — if
you see duplicates, the `nonce` column is likely nil (Postgres treats
nulls as distinct in unique indexes). The router extracts `nonce` from
`signedMessage`; if Google sent one without it, schema validation
rejects it as `:invalid` and logs `"WalletPasses: Google callback
rejected by schema validation"` — check your logs.
## API Reference
### Top-level
- **`WalletPasses.google_save_url/3`** — `(pass_data, google_visual, opts) :: {:ok, url} | {:error, _}`.
Class auto-creation + object create/update + JWT signing. Options:
`:class_id`, `:class_config`, `:origins`, `:translations`.
- **`WalletPasses.update_google_pass/3`** — `(pass_data, google_visual, opts) :: {:ok, object_id} | {:error, _}`.
PATCHes an existing object. Options: `:class_id`, `:class_config`,
`:translations`. Errors: `:not_found`, `:no_object_id`.
- **`WalletPasses.void_pass/1`** / **`expire_pass/1`** / **`complete_pass/1`** / **`reactivate_pass/1`** —
Lifecycle state transitions. See [Pass Lifecycle & Updates](lifecycle.md).
- **`WalletPasses.wallet_presence/1`** — `(serial) :: %{apple: bool, google: bool | nil}`.
### `WalletPasses.Google.Api`
- **`build_pass_object/3`** — `(pass_data, visual, opts) :: map`.
Returns the pass-object map without making any network call. Options:
`:class_id`, `:translations`.
- **`build_class_object/1`** — `(class_config) :: map`. Returns the
class map. `class_config` keys: `:id` (required), `:issuer_name`
(required), `:event_name` (required), `:pass_type`, `:start_date`,
`:end_date`, `:location_name`, `:location_address`, `:latitude`,
`:longitude`, `:logo_uri`, `:enable_smart_tap`, `:redemption_issuers`,
`:translations`.
- **`create_or_update_class/2`** — `(class_config, pass_type) :: {:ok, body} | {:error, _}`.
PUT to `<base>/<class_resource>/<id>`, POST on 404. Emits
`[:wallet_passes, :google, :create_or_update_class, ...]` telemetry.
- **`ensure_class/2`** — `(class_config, pass_type) :: :ok | {:error, _}`.
`create_or_update_class/2` wrapped in a per-VM ETS no-op cache. Key is
`{:class_ensured, class_id}`.
- **`create_object/3`** — `(pass_data, visual, opts) :: {:ok, object_id} | {:error, _}`.
POST, with PUT fallback on 409. Telemetry:
`[:wallet_passes, :google, :create_object, ...]`.
- **`update_object/4`** — `(pass_data, visual, object_id, opts) :: {:ok, object_id} | {:error, _}`.
PATCH. Telemetry: `[:wallet_passes, :google, :update_object, ...]`.
- **`update_object_state/3`** — `(pass_type, object_id, state) :: {:ok, resp} | {:error, _}`.
`state` must be `"ACTIVE"` | `"INACTIVE"` | `"EXPIRED"` | `"COMPLETED"`.
Telemetry: `[:wallet_passes, :google, :update_object_state, ...]`.
- **`get_access_token/0`** — `() :: {:ok, token} | {:error, _}`.
Returns the cached OAuth2 token; refreshes on miss. Telemetry:
`[:wallet_passes, :google, :token_exchange, ...]` with `%{cached: bool}`.
### `WalletPasses.Google.SaveUrl`
- **`url/2`** — `(pass_object, opts) :: {:ok, url} | {:error, _}`.
Wraps `build_jwt/2` in `https://pay.google.com/gp/v/save/<jwt>`.
Options: `:origins`, `:pass_type`. Telemetry:
`[:wallet_passes, :google, :save_url, ...]` with
`%{serial_number: serial}`.
- **`build_jwt/2`** — `(pass_object, opts) :: {:ok, jwt} | {:error, _}`.
Signs the save-to-wallet JWT with the service account's private key.
### `WalletPasses.Google.Visual`
Struct with `:background_color`, `:logo_uri`, `:hero_image_uri`,
`:wide_logo_uri`, `:image_modules` (list of `{uri, description}` pairs).
### `WalletPasses.Google.Router`
`Plug.Router` exposing `POST /callback`. Verifies the envelope,
records to `wallet_passes_google_callbacks`, dispatches the
`:pass_added` / `:pass_removed` event. Forward to it from your Phoenix
router.
### `WalletPasses.Google.CallbackVerifier`
- **`verify/2`** — `(envelope, issuer_id) :: {:ok, signed_message_str} | {:error, atom}`.
Pure OTP verification using `:public_key` and `:crypto`. Caches
Google's root keys in `TokenCache` under `:google_callback_root_keys`.
### Schema
- **`WalletPasses.Schema.GooglePass`** — `wallet_passes_google` table.
Columns: `serial_number`, `object_id`, `status`, `pass_type`.
- **`WalletPasses.Schema.GoogleCallback`** — `wallet_passes_google_callbacks`
table. Columns: `google_pass_id`, `event_type`, `object_id`, `class_id`,
`nonce`, `exp_time_millis`, `received_at`. Unique on
`(google_pass_id, nonce)`.
- **`WalletPasses.Schema.get_google_pass/1`**, **`get_or_create_google_pass/2`**,
**`update_google_object_id/2`**, **`record_google_callback/2`**,
**`latest_google_callback/1`**, **`google_callback_history/1`**.
### Config keys (Application env)
| Key | Required | Purpose |
|--------------------------------|----------|----------------------------------------------------|
| `:google_issuer_id` | Yes | Issuer ID (digits) — prefix for every object/class |
| `:google_service_account_json` | Yes | Service account JSON: path, or inline JSON string |
| `:google_callback_url` | No | Public callback URL; without it, no callbacks |
| `:google_api_base_url` | No | Override for tests (default Google base URL) |
| `:google_token_url` | No | OAuth2 token endpoint override |
| `:google_keys_url` | No | Root-key fetch endpoint override |
### Telemetry events
All emitted as `:telemetry.span` start/stop pairs:
- `[:wallet_passes, :google, :create_or_update_class, ...]` — meta `%{class_id, status}`.
- `[:wallet_passes, :google, :create_object, ...]` — meta `%{serial_number, status}`.
- `[:wallet_passes, :google, :update_object, ...]` — meta `%{object_id, status}`.
- `[:wallet_passes, :google, :update_object_state, ...]` — meta `%{object_id, pass_type, state, status}`.
- `[:wallet_passes, :google, :save_url, ...]` — meta `%{serial_number}`.
- `[:wallet_passes, :google, :token_exchange, ...]` — meta `%{cached}`.
See [Telemetry](telemetry.md) for the complete event reference and
recommended attachment patterns.
### Related guides
- [Getting Started](getting-started.md) — service account, config keys.
- [Localization](localization.md) — `LocalizedString` + `translatedValues`.
- [Pass Lifecycle & Updates](lifecycle.md) — `state` transitions.
- [Event Handling & Wallet Presence](event-handling.md) — callback events.
- [Pass Types](pass-types.md) — per-type class shapes and object fields.
- [Theming & Visual Design](theming.md) — `Google.Visual` styling.
- [NFC & Smart Tap](nfc.md) — `enable_smart_tap`, `redemption_issuers`.
- [Telemetry](telemetry.md) — `[:wallet_passes, :google, …]` events.