Event Handling & Wallet Presence

Copy Markdown View Source

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.EventHandler behaviour 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 Task under WalletPasses.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_callbacks table) 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.Router mounting and route shapes.
  • Google callback signature verification. See the Google Wallet guide for the ECv2SigningOnly chain 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.WalletEventHandler
defmodule 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
end

Query 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"
end

That'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:

  1. Verifies the signature against Google's published ECv2SigningOnly public keys (see Google Wallet for details).
  2. Persists the callback to the wallet_passes_google_callbacks audit table — duplicates by (google_pass_id, nonce) are rejected.
  3. 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_removed on :google can drive an order workflow — refund a saved-incentive, dispatch a customer-success ping, archive the pass in your domain model.
  • :pass_removed on :apple generally should not. It might be a token rotation that's about to be followed by a fresh :pass_added within seconds. The safe interpretation is "this device library ID is no longer reachable for push." Track aggregate Apple presence via wallet_presence/1's :apple field 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:

RouteMethodDispatchesMeta
/v1/devices/:device_id/registrations/:pass_type_id/:serial_numberPOST:pass_added%{device_library_id: String.t(), push_token: String.t()}
/v1/devices/:device_id/registrations/:pass_type_id/:serial_numberDELETE:pass_removed%{device_library_id: String.t()}
/v1/passes/:pass_type_id/:serial_numberGET:pass_fetched%{}
/v1/devices/:device_id/registrations/:pass_type_idGETno 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_at timestamp.
  • 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
end

If 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:

RouteMethodDispatchesMeta
/callbackPOST: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:

  1. ECv2SigningOnly signature. The envelope's signature is verified against the intermediateSigningKey, which itself is verified against Google's published root keys.
  2. Issuer ID match. The signed message's intent references the configured :google_issuer_id.
  3. Pass existence. The objectId must correspond to a row in wallet_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.
  4. Nonce uniqueness. The (google_pass_id, nonce) pair must not already exist in wallet_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.
  5. Schema validity. Required fields (event_type in ~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:

  1. Reads :event_handler from application env.
  2. Verifies the module is loaded and exports the right callback for the event (on_pass_added/3, on_pass_removed/3, or on_pass_fetched/3).
  3. Starts a Task on WalletPasses.EventHandler.TaskSupervisor with restart: :temporary — the task is fire-and-forget, never restarted.
  4. The task applys the callback inside a try/rescue/catch block.

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))
true

Exception capture

If your handler raises, throws, or exits, the dispatcher's try/rescue/catch block captures it. The library:

  1. Emits the :stop telemetry event with status: :error, kind, and reason in metadata.
  2. Logs an error via Logger.error/1, including the module, callback, formatted exception, and stacktrace.
  3. 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 ignored

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

:googleboolean() | nil

Three possible values, and the distinction between false and nil is load-bearing:

  • true — the most recent callback for this pass is a save. 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 a del. 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_url configured 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
end

Querying 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
end

Note 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
end

The 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-saved

You 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_removed followed by :pass_added for 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
end

The 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.
end

Recipe 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
end

Custom 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:

  1. Is :event_handler configured? Check Application.get_env(:wallet_passes, :event_handler) in a console. nil means no dispatch will happen.
  2. 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 spell on_pass_added correctly, or you defined it with the wrong arity (it must be 3).
  3. For Apple: did your dev/staging device actually register? A device only POSTs to your webServiceURL if the .pkpass carries a valid webServiceURL and authenticationToken. If you're testing locally with a localhost URL, iOS won't register — it requires HTTPS with a public certificate. Use a tunnel (ngrok, cloudflared) for local dev.
  4. For Google: did the callback URL resolve? :google_callback_url must be set, must be the URL Google can reach (HTTPS public), and must end in /callback if you forwarded to WalletPasses.Google.Router at a parent path. Watch your server access logs to confirm POSTs are landing.
  5. For Google: did verification pass? A 401 response from /callback means signature verification failed. Check the :google_keys_url config and that the issuer ID in the callback matches :google_issuer_id. See Google Wallet for verification details.
  6. For Google: is the pass known to your DB? Callbacks for serials without a row in wallet_passes_google_passes return 200 (Google wants 2xx) but skip dispatch silently. Confirm with WalletPasses.Schema.get_google_pass(serial).
  7. 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:

  1. Apple token rotation. iOS DELETEs and POSTs back-to-back. The library will dispatch :pass_removed followed by :pass_added to your handler, both for the same serial, within seconds. This is not a bug — it's iOS reconciling its push token.
  2. Multi-device users. A user with the same Apple ID on iPhone and Apple Watch will produce two :pass_added events with different device_library_id values. Dedup on device_library_id if you don't want this.
  3. 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:

  1. :google_callback_url isn't configured. Without it, the library doesn't register callbackOptions on your Google class, and Google doesn't send callbacks. nil is the correct value in that case — it means "we have no information."
  2. The class was created before :google_callback_url was set. Class creation is idempotent within a VM lifetime, but the callbackOptions are only set when the class is first created. Updating the class via Google.Api.ensure_class/2 won't backfill callbackOptions. Patch the class directly through the Google Wallet API, or delete and recreate it during dev.
  3. 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.
  4. 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 via Google.Api directly without going through WalletPasses.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_timeSystem.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 with System.convert_time_unit(duration, :native, :millisecond).

Metadata:

  • handler — same as :start.
  • callback — same as :start.
  • status:ok if the handler returned normally, :error if it raised, threw, or exited.
  • kind — only present when status: :error. Either :error, :throw, or :exit.
  • reason — only present when status: :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: save callback received and verified.
  • on_pass_removed(serial, platform, meta) — pass removed. Apple: device unregistered (ambiguous). Google: del callback 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

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 omits callbackOptions from 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.

  • Getting Started — for the full :event_handler configuration walkthrough and minimal EventHandler skeleton.
  • Apple Wallet — for Apple.Router mounting and the Web Service Protocol routes.
  • Google Wallet — for Google.Router mounting, :google_callback_url setup, and ECv2SigningOnly verification.
  • 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.