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 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.
For credential setup (service account JSON, issuer ID, config keys), see the Getting Started guide.
Overview
What's covered
- Save URLs via
WalletPasses.google_save_url/3— a signed JWT wrapped inhttps://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 publishedECv2SigningOnlyroot 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 (eventNamevsprogramNamevstitle); the library picks the right one frompass_type.
What's not covered
- Direct REST calls.
WalletPasses.Google.Apiis 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 fromWalletPasses.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.
- 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.
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.
Quick Start
Configure your service account and issuer ID (see Getting Started for the full walkthrough):
# 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:
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:
# lib/my_app_web/router.ex
forward "/passes/google", WalletPasses.Google.RouterThat'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):
{
"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
DeviceRegistrationtable. - 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:
{
"protocolVersion": "ECv2SigningOnly",
"signedMessage": "{\"objectId\":\"3388....ticket-42\",\"eventType\":\"save\",\"nonce\":\"...\",\"expTimeMillis\":\"...\"}",
"signature": "MEUCIQ...",
"intermediateSigningKey": {
"signedKey": "{\"keyValue\":\"...\",\"keyExpiration\":\"1715980800000\"}",
"signatures": ["MEYCIQ..."]
}
}To verify, the library:
- 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). - Verifies
intermediateSigningKey.signaturesagainst the roots — this proves the intermediate key was issued by Google. - Decodes the intermediate
keyValue(a base64-encoded P-256 public key) and verifiessignatureagainstsignedMessageusing it. - Decodes
signedMessageas JSON and pulls outobjectId,eventType, andnonce.
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:
- Optionally creates or updates the class on Google's servers
(idempotent — see
:class_configbelow). - Looks up or creates a
wallet_passes_googlerow for the serial. - POSTs the pass object to Google's API (or PUTs if the object already exists — handles the 409).
- Stores the resulting
object_idon the DB row. - Builds the JWT and returns the save URL.
{: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 callsGoogle.Api.ensure_class/2before 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. To localize class-level fields, pass:translationsinsideclass_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:
- Pass
class_configtogoogle_save_url/3(orupdate_google_pass/3). The library callsGoogle.Api.ensure_class/2first, which is a no-op after the first successful call per VM lifetime per class. - Call
WalletPasses.Google.Api.create_or_update_class/2directly 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:
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.
# 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.
For per-type field details, see the Pass Types 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:
{: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 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):
# lib/my_app_web/router.ex
forward "/passes/google", WalletPasses.Google.RouterThe full callback URL is the mount path + /callback — e.g.
https://yourdomain.com/passes/google/callback. Configure it:
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:
- Verify the envelope with
CallbackVerifier.verify/2. On failure, respond401. - Extract the serial from
signedMessage.objectId(everything after the first.). - Look up the pass in
wallet_passes_google. If unknown, respond200and ignore. - Insert a
wallet_passes_google_callbacksrow. The(google_pass_id, nonce)unique index makes duplicate callbacks a no-op — Google retries on timeout, and this is your replay protection. - Dispatch a
:pass_addedor:pass_removedevent to yourWalletPasses.EventHandlerasynchronously underTask.Supervisor. - 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:
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
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
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:
{: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:
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
endThen call google_save_url/3 without class_config — the object
references the class you already created:
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), then:
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:
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.WalletEventHandlerGoogle's on_pass_removed is authoritative — the user definitely removed
the pass. (Apple's is ambiguous; see Event Handling.)
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:
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:
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.Apicovers 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 — useApi.get_access_token/0to 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 withJokenand the service account key. - Multiple objects in one save URL. Google supports JWTs that save
several passes at once (the
payloadis 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
authenticationTokenon the object, no parallel ofApple.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
eventNamewill 400 — Google expectsprogramName. Checkpass_typeinclass_configmatchespass_data.pass_type. - Required field missing. Loyalty classes need
issuerNameandprogramName. Offers needtitle. Event tickets needeventName. The library writes these fromclass_config[:event_name], but if you've omitted:event_nameentirely 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.latitudeandlongitudemust both be numbers. The library only emitslocationswhen 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 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_jsonis 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:
- 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. ChecksignedMessage.protocolVersionin the request body. - Are root keys reachable? The library fetches
https://pay.google.com/gp/m/issuer/keyson the first verification per VM lifetime. If your egress firewall blocks it, every verification fails. Set:google_keys_urlin config to a mock for testing if you need to. - Is
:google_issuer_idset 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. - Is your tunnel intercepting the body? Some local dev proxies
(
ngrokHTTP rewriting,localhost.runwith 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:
:google_callback_urlis unset. Without it,build_class_object/1doesn't emitcallbackOptions, and Google doesn't know where to POST. CheckApplication.get_env(:wallet_passes, :google_callback_url)at runtime — it must be the publicly reachable URL Google will hit.- The class was created before
:google_callback_urlwas set. ThecallbackOptionsis a class-level field, so adding the config after the class exists doesn't retroactively enable callbacks. Re-callGoogle.Api.create_or_update_class/2on the affected class, or delete the ETS cache flag and letensure_class/2re-PUT it. - The URL isn't reachable from Google. Your local
https://localhost:4000/...URL won't work — Google has to hit a public IP. Usengrokor 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:
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}"}]
).bodyThe 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:
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.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_configkeys::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/2wrapped 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, _}.statemust 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, _}. Wrapsbuild_jwt/2inhttps://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_keyand:crypto. Caches Google's root keys inTokenCacheunder:google_callback_root_keys.
Schema
WalletPasses.Schema.GooglePass—wallet_passes_googletable. Columns:serial_number,object_id,status,pass_type.WalletPasses.Schema.GoogleCallback—wallet_passes_google_callbackstable. 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 for the complete event reference and recommended attachment patterns.
Related guides
- Getting Started — service account, config keys.
- Localization —
LocalizedString+translatedValues. - Pass Lifecycle & Updates —
statetransitions. - Event Handling & Wallet Presence — callback events.
- Pass Types — per-type class shapes and object fields.
- Theming & Visual Design —
Google.Visualstyling. - NFC & Smart Tap —
enable_smart_tap,redemption_issuers. - Telemetry —
[:wallet_passes, :google, …]events.