This guide explains how to react to wallet pass lifecycle events from both
Apple and Google with wallet_passes, and how to query the current presence
of a pass on each platform. Apple Wallet and Google Wallet emit very
different signals, and they don't agree on what "removed" means — this
library exposes a single WalletPasses.EventHandler behaviour that
normalises both into three callbacks, and a wallet_presence/1 function
that surfaces the per-platform truth honestly (including a nil case that
matters).
Overview
What's covered
- Implementing the
WalletPasses.EventHandlerbehaviour with three optional callbacks (on_pass_added/3,on_pass_removed/3,on_pass_fetched/3). - The async supervised dispatch model — your handler runs in a
TaskunderWalletPasses.EventHandler.TaskSupervisor, so a slow callback can never extend Apple's iOS response time or Google's callback timeout. - The per-platform metadata shape passed to each callback.
WalletPasses.wallet_presence/1— querying current saved-state on each platform.- The Google callback audit log (
wallet_passes_google_callbackstable) and how to query it directly. - Telemetry on dispatch (
[:wallet_passes, :event_handler, :dispatch, :start]and:stop). - Boot-time validation that warns if the configured handler exports none of the optional callbacks.
What's not covered
- The Apple Web Service Protocol routes themselves. See the
Apple Wallet guide for
WalletPasses.Apple.Routermounting and route shapes. - Google callback signature verification. See the
Google Wallet guide for the
ECv2SigningOnlychain validation that runs before any event is dispatched. - Lifecycle transitions (
void_pass/1,expire_pass/1,complete_pass/1,reactivate_pass/1). Those are issuer-driven state changes, not user events. See Pass Lifecycle & Updates. - Custom dispatchers. The library only ships one dispatcher
(
WalletPasses.EventHandler.Dispatch) and one supervisor. If you want per-tenant routing or message-bus fan-out, do it inside your handler.
Why the two platforms differ
Apple Wallet treats devices as the source of truth — iOS POSTs to your
server when it registers for push, DELETEs when it unregisters, and GETs the
.pkpass when it wants the latest version. The DELETE call fires for any
reason a device can become unreachable: a user genuinely deleting the pass,
iOS rotating the device's push token, the user toggling notifications off
for the pass, or simply removing the app that delivered the pass. Apple
unregister is ambiguous — the protocol doesn't disambiguate.
Google Wallet treats its own servers as the source of truth — when a user
saves or deletes a pass through the Google Wallet app or web flow, Google
POSTs a signed callback to your server. The callback's eventType is
either save or del, and del means exactly what it says: the user
removed the pass. Google removal is authoritative.
This library doesn't paper over the difference. The :platform argument
passed to each callback tells you which world you're in, and
wallet_presence/1 reports the two platforms separately so you can apply
the right interpretation to each.
Quick Start
Configure an event handler module and implement only the callbacks you care about. All three are optional.
# config/config.exs
config :wallet_passes,
event_handler: MyApp.WalletEventHandlerdefmodule MyApp.WalletEventHandler do
@behaviour WalletPasses.EventHandler
@impl true
def on_pass_added(serial, :google, _meta) do
MyApp.Orders.mark_saved_to_wallet(serial)
end
def on_pass_added(serial, :apple, %{device_library_id: device, push_token: token}) do
MyApp.Telemetry.track_apple_register(serial, device, token)
end
@impl true
def on_pass_removed(serial, :google, _meta) do
# Definitive: user removed the pass from their Google Wallet.
MyApp.Orders.mark_pass_removed(serial)
end
def on_pass_removed(_serial, :apple, _meta) do
# Ambiguous on Apple — see Concepts. Treat as "device unreachable," not
# "user deleted." Often the right call is to do nothing here.
:ok
end
endQuery current presence at any time:
case WalletPasses.wallet_presence("TICKET-42") do
%{apple: true, google: true} -> "Saved on both platforms"
%{apple: true, google: nil} -> "Saved on Apple, no Google callback yet"
%{apple: true, google: false} -> "Saved on Apple, removed from Google"
%{apple: false, google: true} -> "Saved on Google only"
%{apple: false, google: false} -> "Removed from Google, no Apple devices"
%{apple: false, google: nil} -> "Not yet saved anywhere"
endThat's the whole surface. The rest of this guide explains what each
callback means, when it fires, what's in meta, and how to use the audit
log and telemetry for richer flows.
Concepts
What Apple emits
Apple Wallet on iOS implements the PassKit Web Service
Protocol.
When you serve a .pkpass with a webServiceURL, iOS makes three kinds
of requests to your server:
POST .../registrations/.../:serial— "I have this pass, push me when it changes. Here's my APNs token." The library treats this as:pass_added.DELETE .../registrations/.../:serial— "stop pushing me about this pass." The library treats this as:pass_removed. The DELETE fires for any reason the registration becomes invalid:- The user opened the pass and tapped Remove.
- iOS rotated the device's APNs push token (the OS does this on its own schedule; the old registration is invalidated and a new POST follows with the new token).
- The user uninstalled the app that delivered the pass.
- The user turned off notifications for the pass.
- iOS garbage-collected the registration.
GET .../passes/.../:serial— "give me the latest pass." The library treats this as:pass_fetched. iOS may do this several times per day per device.
There is no "the user has deleted this pass" event in the protocol. DELETE is the only signal you get, and it can mean any of the above.
What Google emits
Google Wallet doesn't run server-to-device sync — Google's servers hold the
truth about which users have which passes saved. When a user saves a pass
(via a Save URL, a Smart Tap, or the Google Wallet web app) or removes one
(via the Wallet app's pass detail screen), Google POSTs a signed JSON
envelope to the URL configured at :google_callback_url.
The envelope contains a SignedMessage
payload
with an eventType of either "save" or "del", plus the objectId,
classId, a nonce (for idempotency), and expTimeMillis. The library:
- Verifies the signature against Google's published
ECv2SigningOnlypublic keys (see Google Wallet for details). - Persists the callback to the
wallet_passes_google_callbacksaudit table — duplicates by(google_pass_id, nonce)are rejected. - Dispatches a
:pass_added(for"save") or:pass_removed(for"del") event to your handler.
The signature verification, persistence, and duplicate-nonce check all run
before dispatch. Your handler sees exactly one :pass_added per genuine
save and exactly one :pass_removed per genuine deletion. Google's del
event is authoritative: when you see it, the user really removed the
pass.
Why this matters for your handler
Because Apple's :pass_removed is ambiguous and Google's is
authoritative, the two should drive different kinds of work:
:pass_removedon:googlecan drive an order workflow — refund a saved-incentive, dispatch a customer-success ping, archive the pass in your domain model.:pass_removedon:applegenerally should not. It might be a token rotation that's about to be followed by a fresh:pass_addedwithin seconds. The safe interpretation is "this device library ID is no longer reachable for push." Track aggregate Apple presence viawallet_presence/1's:applefield rather than reacting per-event.
If you need an authoritative "the user no longer has this pass" signal on Apple, you don't have one. There is no perfect answer.
How It Works: Apple
Dispatch sites in Apple.Router
WalletPasses.Apple.Router is a Plug.Router with four routes. Three of
them dispatch events to your handler after their primary work succeeds:
| Route | Method | Dispatches | Meta |
|---|---|---|---|
/v1/devices/:device_id/registrations/:pass_type_id/:serial_number | POST | :pass_added | %{device_library_id: String.t(), push_token: String.t()} |
/v1/devices/:device_id/registrations/:pass_type_id/:serial_number | DELETE | :pass_removed | %{device_library_id: String.t()} |
/v1/passes/:pass_type_id/:serial_number | GET | :pass_fetched | %{} |
/v1/devices/:device_id/registrations/:pass_type_id | GET | no dispatch | — |
The "list registrations for a device" route doesn't fire an event — it's a sync mechanism iOS uses to reconcile its registration list with yours, not a user action.
The dispatch happens after the persistence step succeeds and before the response is sent. Because dispatch is asynchronous, the response is sent immediately — iOS never waits for your handler.
What's in meta on Apple
:pass_added from Apple includes both the device_library_id (Apple's
opaque per-device identifier) and the push_token (the APNs device token
you need to send pushes to). The push token is what
Schema.list_push_tokens_for_serial/1 returns and what
WalletPasses.notify_apple_devices/1 consumes.
:pass_removed from Apple includes only the device_library_id — Apple's
unregister request doesn't carry the push token. If you need to correlate
removals to the original registration, key on device_library_id.
:pass_fetched from Apple has an empty meta map. Apple's GET request
carries no user-data beyond the URL parameters (serial number and pass
type ID, which are part of the dispatched call's first arguments).
Apple's :pass_fetched is high-volume
iOS fetches the latest pass content speculatively, often in response to
silent pushes but also when the user opens Wallet, when the device comes
back online, or on its own schedule. A single device with a saved pass can
easily produce a handful of :pass_fetched events per day.
Do not write a row per fetch to your domain database without rate-limiting. Common patterns that work:
- Bucket by serial + day and upsert a
last_fetched_attimestamp. - Sample fetches at a fixed rate for analytics (one in twenty, say).
- Skip the callback entirely if you don't have a use case for it (it's
optional — just don't implement
on_pass_fetched/3).
Don't take destructive action on Apple :pass_removed
Because the DELETE request carries no reason code and any of the ambiguity cases listed under Concepts can produce it, the safe framing in your handler is:
def on_pass_removed(_serial, :apple, _meta) do
# Don't drive a refund, cancellation, or domain state change from this.
# If you need to track Apple presence, rely on wallet_presence/1's
# :apple field, which queries the registrations table directly.
:ok
endIf you want a metric, treat :pass_removed on :apple as "device
fan-out shrunk for this serial" — a counter, not a domain event.
How It Works: Google
Dispatch site in Google.Router
WalletPasses.Google.Router has a single dispatching route:
| Route | Method | Dispatches | Meta |
|---|---|---|---|
/callback | POST | :pass_added or :pass_removed | %{object_id: String.t(), class_id: String.t(), nonce: String.t(), exp_time_millis: integer()} |
:pass_added fires for eventType: "save"; :pass_removed fires for
eventType: "del".
Verification gates dispatch
A callback only reaches the dispatcher after passing the full verification gauntlet:
ECv2SigningOnlysignature. The envelope'ssignatureis verified against theintermediateSigningKey, which itself is verified against Google's published root keys.- Issuer ID match. The signed message's
intentreferences the configured:google_issuer_id. - Pass existence. The
objectIdmust correspond to a row inwallet_passes_google_passes(matched by the suffix after the issuer ID prefix). Callbacks for unknown passes are silently accepted with a 200 but never dispatched. - Nonce uniqueness. The
(google_pass_id, nonce)pair must not already exist inwallet_passes_google_callbacks. Duplicates are silently accepted with a 200 but not dispatched — Google retries on timeout, and this is how the library guarantees at-most-once dispatch per genuine event. - Schema validity. Required fields (
event_typein~w(save del),object_id,class_id,nonce,received_at) must pass changeset validation. Malformed callbacks are silently accepted with a 200 but not dispatched, and a warning is logged.
Only after all five pass does dispatch happen. This means: when your
on_pass_added or on_pass_removed for :google fires, the event is
signed by Google, scoped to your issuer, attached to a known pass, novel,
and well-formed.
The audit log
Every verified callback for a known pass becomes a row in
wallet_passes_google_callbacks. Callbacks for unknown serials (no
matching google_passes row) are silently accepted with a 200 but
never persisted or dispatched.
The row carries the event_type ("save" or "del"), object_id,
class_id, nonce, exp_time_millis, and received_at. A unique
constraint on (google_pass_id, nonce) enforces idempotency at the
database level. The audit log is the underlying truth for
wallet_presence/1's :google field — Schema.latest_google_callback/1
returns the most recent row, and its event_type decides
true/false. No callback recorded → nil.
You can query the log directly for richer flows (see Recipe 3 below).
Apple has no analogue
Apple's only removal signal is the DELETE registration call described
above, and the library does not persist an Apple event history.
Apple's truth is "which devices currently have a registration"
(wallet_pass_device_registrations), not "what was the most recent
event for this serial." A pass that's been registered, unregistered, and
registered again on the same device shows up as one row with the latest
push token — there's no history.
The Dispatch Model
Async, supervised, fire-and-forget
WalletPasses.EventHandler.Dispatch.dispatch/4 is what every router calls:
Dispatch.dispatch(:pass_added, serial_number, :apple, %{
device_library_id: device_id,
push_token: push_token
})The function returns :ok immediately. Internally it:
- Reads
:event_handlerfrom application env. - Verifies the module is loaded and exports the right callback for the
event (
on_pass_added/3,on_pass_removed/3, oron_pass_fetched/3). - Starts a
TaskonWalletPasses.EventHandler.TaskSupervisorwithrestart: :temporary— the task is fire-and-forget, never restarted. - The task
applys the callback inside atry/rescue/catchblock.
Because the router caller never Task.awaits the dispatched task, your
handler's runtime is decoupled from the HTTP response. A handler that
sleeps for a minute will not delay Apple's response to iOS, will not
delay Google's response to its callback POST, and will not block the
calling process for subsequent events.
Where the supervisor lives
WalletPasses.EventHandler.TaskSupervisor is started under the library's
top-level supervisor in WalletPasses.Application:
children = [
{Task.Supervisor, name: WalletPasses.EventHandler.TaskSupervisor},
]There's nothing for you to do to enable it — it starts when the application starts. You can verify it's running:
iex> is_pid(Process.whereis(WalletPasses.EventHandler.TaskSupervisor))
trueException capture
If your handler raises, throws, or exits, the dispatcher's try/rescue/catch
block captures it. The library:
- Emits the
:stoptelemetry event withstatus: :error,kind, andreasonin metadata. - Logs an error via
Logger.error/1, including the module, callback, formatted exception, and stacktrace. - The task exits normally (the failure is consumed).
The supervisor doesn't restart the task. The HTTP request that triggered dispatch is unaffected — iOS got a 201, Google got a 200, neither retries.
Concretely: if on_pass_added raises every time, every save event you
get from Google will log an error and emit dispatch:stop with
status: :error, but no other side effects happen. The pass is still
recorded in your audit log, and wallet_presence/1 still reports true.
Boot-time validation
When the application starts, an internal validate_event_handler/0 step in
WalletPasses.Application checks the configured handler. If :event_handler is set to a module that
doesn't export any of on_pass_added/3, on_pass_removed/3, or
on_pass_fetched/3, the library logs a warning once at boot:
configured :event_handler MyApp.WalletEventHandler does not export any of
[on_pass_added/3, on_pass_removed/3, on_pass_fetched/3] — events will be silently ignoredThis catches the common typo bugs: forgetting @impl true plus typo'ing
the function name (on_pass_add instead of on_pass_added), or
configuring the wrong module entirely (MyApp.WalletEventHandler vs
MyApp.WalletEventsHandler). At runtime, dispatches to a handler that
doesn't export the called callback are silently ignored (the with
short-circuits) — the boot warning is the only signal.
wallet_presence/1 Semantics
@spec wallet_presence(String.t()) :: %{apple: boolean(), google: boolean() | nil}:apple — boolean
true when at least one row exists in wallet_pass_device_registrations
for this serial. false otherwise.
This is a "reachable for push" signal, not a "user has the pass" signal.
A device that registered, then had its push token rotated by iOS, then
re-registered with the new token shows up as a single row — you can't
tell from this field alone whether the user actively has the pass. In
practice, :apple => true is a strong indicator the user has the pass,
because the registration is renewed every time iOS opens the pass; but
it's not authoritative.
:google — boolean() | nil
Three possible values, and the distinction between false and nil is
load-bearing:
true— the most recent callback for this pass is asave. Google has told us, with a valid signature, that the user has the pass right now.false— the most recent callback for this pass is adel. Google has told us, with a valid signature, that the user removed the pass.nil— no callback has ever been recorded for this pass. Either (a) Google has never sent us anything (the user has never saved, or has saved but the callback hasn't arrived yet — they're typically <1s), or (b) you don't have:google_callback_urlconfigured and so Google doesn't send callbacks at all.
The library never collapses nil and false. If your code conflates them
(e.g. if presence.google do ... else ...), you'll treat "no information
yet" the same as "user removed the pass." Use explicit matches:
case WalletPasses.wallet_presence(serial) do
%{google: true} -> :saved
%{google: false} -> :removed
%{google: nil} -> :unknown
endQuerying isn't free
wallet_presence/1 issues two database queries per call (one for Apple
registrations, one for the latest Google callback). For high-throughput
pages, cache the result or denormalise into your domain model from inside
the event handler.
Recipes
Recipe 1: Mark orders as saved on Google
The most common use case for on_pass_added. Drive a domain-model state
change off the authoritative save event.
defmodule MyApp.WalletEventHandler do
@behaviour WalletPasses.EventHandler
@impl true
def on_pass_added(serial, :google, _meta) do
case MyApp.Orders.find_by_serial(serial) do
nil -> :ok
order ->
MyApp.Orders.update_pass_status(order, :saved_to_google)
MyApp.Notifications.queue_thank_you_email(order)
end
end
def on_pass_added(_serial, :apple, _meta), do: :ok
endNote that this assumes idempotency on your side — see Recipe 4. Google
guarantees at-most-once dispatch per nonce (the library's duplicate
filter), but two separate user actions can produce two on_pass_added
calls (save → del → save). Make update_pass_status and queue_thank_you_email
safe to call repeatedly.
Recipe 2: Refund flow on Google removal
on_pass_removed for :google is authoritative — a good place to drive a
refund or cancellation workflow.
def on_pass_removed(serial, :google, _meta) do
with order when not is_nil(order) <- MyApp.Orders.find_by_serial(serial),
:ok <- MyApp.Orders.cancel_unused(order) do
MyApp.Notifications.queue_removal_followup(order)
else
nil -> :ok
{:error, :already_used} -> :ok
end
endThe with here guards two real cases: a stranger's pass we don't recognise
(nil), and a pass that's already been used and so can't be cancelled.
Both are fine — the callback was valid, we just don't have business work
to do.
Recipe 3: Querying the audit log directly
For richer flows — "how many times has this pass been saved and removed?"
— go to the audit log directly. The library exposes
Schema.latest_google_callback/1 and Schema.google_callback_history/1:
alias WalletPasses.Schema
# The single most recent callback.
case Schema.latest_google_callback("TICKET-42") do
nil -> :no_activity
%{event_type: "save", received_at: at} -> {:saved_at, at}
%{event_type: "del", received_at: at} -> {:removed_at, at}
end
# Every callback in order, oldest first.
"TICKET-42"
|> Schema.google_callback_history()
|> Enum.map(& &1.event_type)
# => ["save", "del", "save"] — user saved, removed, then re-savedYou can use the history to detect re-engagement patterns ("user removed then re-saved within 24 hours") or to compute the actual saved-duration for a pass that's been removed.
Recipe 4: Idempotent handlers
Both platforms can deliver the same logical event more than once across different nonces and across time:
- Apple: a device that unregisters and re-registers (token rotation) will
send DELETE followed by POST. Your handler sees
:pass_removedfollowed by:pass_addedfor the same serial. - Google: a user can save, remove, and re-save a pass. Each is a separate callback with a separate nonce, so all three dispatch.
Write your handler so any single callback can run twice without harm:
def on_pass_added(serial, :google, _meta) do
# mark_saved_to_wallet is an upsert: setting status to :saved twice is a no-op.
MyApp.Orders.mark_saved_to_wallet(serial)
end
def on_pass_removed(serial, :google, _meta) do
# cancel_unused returns :already_cancelled on a no-op.
case MyApp.Orders.cancel_unused(serial) do
:ok -> notify_user(serial)
:already_cancelled -> :ok
end
endThe library guarantees at-most-once dispatch per Google callback nonce
via the unique constraint on (google_pass_id, nonce). It does not
guarantee at-most-once per user action — a user pressing Save, then
Remove, then Save again produces three separate nonces and three
dispatches.
Recipe 5: Conditional dispatch — drop fetched events
If you don't have a use for on_pass_fetched, just don't define it. The
library checks function_exported?(handler, :on_pass_fetched, 3) before
dispatching; if you don't export it, no Task is started and the call
short-circuits.
defmodule MyApp.WalletEventHandler do
@behaviour WalletPasses.EventHandler
def on_pass_added(_, _, _), do: :ok
def on_pass_removed(_, _, _), do: :ok
# No on_pass_fetched — the high-volume Apple GET events vanish entirely.
endRecipe 6: Telemetry handler for dispatch latency
Attach to [:wallet_passes, :event_handler, :dispatch, :stop] to measure
your handler's runtime and error rate:
:telemetry.attach(
"wallet-handler-metrics",
[:wallet_passes, :event_handler, :dispatch, :stop],
fn _event, %{duration: duration_native}, metadata, _config ->
duration_ms = System.convert_time_unit(duration_native, :native, :millisecond)
MyApp.Metrics.histogram("wallet_handler.duration_ms", duration_ms,
tags: %{
callback: metadata.callback,
status: metadata.status
}
)
if metadata.status == :error do
MyApp.Metrics.counter("wallet_handler.error",
tags: %{
callback: metadata.callback,
kind: metadata.kind
}
)
end
end,
nil
)See the Telemetry guide for the full list of events.
What's NOT Covered
The library deliberately leaves some patterns to consumers.
Pre-OTP-app handler patterns and runtime fan-out
There's one :event_handler module configured at application boot — no
runtime registry, no GenServer-based subscription API. If you want
runtime fan-out, do it inside your handler:
def on_pass_added(serial, platform, meta) do
for subscriber <- MyApp.WalletSubscribers.list() do
send(subscriber, {:wallet_event, :pass_added, serial, platform, meta})
end
endCustom dispatchers
WalletPasses.EventHandler.Dispatch is hard-wired into both routers —
no configuration to swap it out. If you need a different dispatch model
(synchronous, queued, RPC, per-tenant routing), forward events to your
own transport from inside your handler module. The library doesn't know
about tenants or transports — it just dispatches the raw event with the
serial number and platform.
Replaying past events
There's no "replay all callbacks for this pass" API. To backfill an
audit pipeline, query Schema.google_callback_history/1 and call your
downstream handler yourself. Apple registrations have no history (only
current state), so there's no equivalent replay for Apple.
Troubleshooting
"My handler isn't being called"
Run through this checklist:
- Is
:event_handlerconfigured? CheckApplication.get_env(:wallet_passes, :event_handler)in a console.nilmeans no dispatch will happen. - Did the application start cleanly? Check the boot logs for the
configured :event_handler … does not export any of [...]warning. If you see it, your module exports the wrong function names — likely you forgot to spellon_pass_addedcorrectly, or you defined it with the wrong arity (it must be 3). - For Apple: did your dev/staging device actually register? A device
only POSTs to your
webServiceURLif the.pkpasscarries a validwebServiceURLandauthenticationToken. If you're testing locally with alocalhostURL, iOS won't register — it requires HTTPS with a public certificate. Use a tunnel (ngrok,cloudflared) for local dev. - For Google: did the callback URL resolve?
:google_callback_urlmust be set, must be the URL Google can reach (HTTPS public), and must end in/callbackif you forwarded toWalletPasses.Google.Routerat a parent path. Watch your server access logs to confirm POSTs are landing. - For Google: did verification pass? A 401 response from
/callbackmeans signature verification failed. Check the:google_keys_urlconfig and that the issuer ID in the callback matches:google_issuer_id. See Google Wallet for verification details. - For Google: is the pass known to your DB? Callbacks for serials
without a row in
wallet_passes_google_passesreturn 200 (Google wants 2xx) but skip dispatch silently. Confirm withWalletPasses.Schema.get_google_pass(serial). - Did the callback's nonce arrive before? Duplicate nonces are
silently dropped (idempotency). Confirm with
Schema.google_callback_history(serial)— if the nonce is already there, the second copy was filtered.
"My handler raised but the request still got a 2xx"
That's correct behaviour. Dispatch is asynchronous — the response to iOS
or Google is sent before your handler runs, and it isn't tied to your
handler's return value. A handler crash is captured, logged via
Logger.error/1, and reported via the :dispatch, :stop telemetry event
with status: :error. The HTTP request is otherwise unaffected.
Check your application logs for WalletPasses event handler crashed: ...
to find the stacktrace.
"I'm getting duplicate events for the same user action"
Three things can cause this:
- Apple token rotation. iOS DELETEs and POSTs back-to-back. The
library will dispatch
:pass_removedfollowed by:pass_addedto your handler, both for the same serial, within seconds. This is not a bug — it's iOS reconciling its push token. - Multi-device users. A user with the same Apple ID on iPhone and
Apple Watch will produce two
:pass_addedevents with differentdevice_library_idvalues. Dedup ondevice_library_idif you don't want this. - A user actually saving, removing, and re-saving on Google. This produces three separate nonces, each dispatched. No way to filter without losing genuine re-engagements — handle it in your domain model (see Recipe 4).
"wallet_presence/1 says :google is nil but the user definitely saved the pass"
Possibilities:
:google_callback_urlisn't configured. Without it, the library doesn't registercallbackOptionson your Google class, and Google doesn't send callbacks.nilis the correct value in that case — it means "we have no information."- The class was created before
:google_callback_urlwas set. Class creation is idempotent within a VM lifetime, but thecallbackOptionsare only set when the class is first created. Updating the class viaGoogle.Api.ensure_class/2won't backfillcallbackOptions. Patch the class directly through the Google Wallet API, or delete and recreate it during dev. - The callback hit your server but failed verification. Look for
401 responses in your logs from
/passes/google/callback. See Google Wallet for verification troubleshooting. - The callback hit your server, verified, but the pass row is
missing. Confirm
WalletPasses.Schema.get_google_pass(serial)returns a row. If it doesn't, the callback was dropped silently. This happens if you issue Save URLs viaGoogle.Apidirectly without going throughWalletPasses.google_save_url/3— only the top-level helper creates the DB row.
"Apple :pass_removed fires but the user still has the pass"
This is the ambiguity described under
How It Works: Apple. Don't treat Apple's
:pass_removed as a domain event without corroboration. If you need
authoritative "user removed the pass" on Apple, you don't have one — see
Concepts for the rationale.
"Two passes are getting cross-wired in my handler"
The handler's serial_number argument is the only identifier in the
callback. Make sure your serial-number namespace is globally unique. If
you reuse serials across pass types (e.g. ticket #42 and loyalty card #42
both have serial = "42"), the handler can't distinguish them — they'll
share callbacks.
"My handler runs but the side effect doesn't happen"
Tasks under Task.Supervisor run in their own process. In tests with a
shared Ecto sandbox, allow the sandbox connection — see
Ecto.Adapters.SQL.Sandbox.allow/3.
For non-test code: add a Logger.info("entered on_pass_added: #{inspect(serial)}")
at the top of your handler. If the log doesn't appear but the dispatch
telemetry's :start event fires, your code is throwing before the log
line. If neither fires, dispatch isn't happening — return to "My handler
isn't being called."
Telemetry
The dispatcher emits two telemetry events per dispatched callback.
[:wallet_passes, :event_handler, :dispatch, :start]
Measurements:
system_time—System.system_time()when the dispatch began.
Metadata:
handler— the configured handler module atom.callback— one of:on_pass_added,:on_pass_removed,:on_pass_fetched.
[:wallet_passes, :event_handler, :dispatch, :stop]
Measurements:
duration— monotonic native time units. Convert withSystem.convert_time_unit(duration, :native, :millisecond).
Metadata:
handler— same as:start.callback— same as:start.status—:okif the handler returned normally,:errorif it raised, threw, or exited.kind— only present whenstatus: :error. Either:error,:throw, or:exit.reason— only present whenstatus: :error. The exception, thrown value, or exit reason.
Both events fire regardless of the handler's return value (returns are
ignored). The :stop event is the right hook for histograms,
success-rate counters, and error alerting. See the
Telemetry guide for the library's full event catalog.
API Reference
Behaviour callbacks (WalletPasses.EventHandler)
All three are @optional_callbacks. Implement only what you care about.
Return values are ignored.
on_pass_added(serial, platform, meta)— pass added. Apple: device registered for push. Google:savecallback received and verified.on_pass_removed(serial, platform, meta)— pass removed. Apple: device unregistered (ambiguous). Google:delcallback received and verified (authoritative).on_pass_fetched(serial, platform, meta)— Apple device fetched the latest pass content. No Google analogue. High-volume — rate-limit any persistence.
Functions
WalletPasses.wallet_presence/1— returns%{apple: boolean(), google: boolean() | nil}.:googleisnilwhen no callback has been recorded;falseonly when the most recent callback wasdel. Two database queries per call.WalletPasses.Schema.latest_google_callback/1— returns the single most recentGoogleCallbackrow, ordered byiddescending.nilif no callback exists. Underlieswallet_presence/1's:googlefield.WalletPasses.Schema.google_callback_history/1— returns every callback for the serial, oldest first. Use for re-engagement analytics and audit reporting.WalletPasses.EventHandler.Dispatch.dispatch/4— the internal dispatch function called by the routers. Returns:okimmediately; the handler runs asynchronously underWalletPasses.EventHandler.TaskSupervisor. Exposed for testing.
Configuration
:event_handler— required to receive events. Without it, all dispatch calls short-circuit silently.:google_callback_url— required to receive Google callbacks. Without it, the library omitscallbackOptionsfrom the Google class. See Google Wallet.
Supervision tree
WalletPasses.EventHandler.TaskSupervisor is started under
WalletPasses.Supervisor at application boot. Every dispatched
callback runs as a restart: :temporary task here.
Forward links
- Getting Started — for the full
:event_handlerconfiguration walkthrough and minimalEventHandlerskeleton. - Apple Wallet — for
Apple.Routermounting and the Web Service Protocol routes. - Google Wallet — for
Google.Routermounting,:google_callback_urlsetup, andECv2SigningOnlyverification. - Pass Lifecycle & Updates — for issuer-driven state
changes (
void_pass/1, etc.) that complement user-driven events. - Telemetry — for the full catalog of dispatch and other telemetry events.