Skip to main content

guides/event-handling.md

# Event Handling & Wallet Presence

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](apple-wallet.md) guide for `WalletPasses.Apple.Router`
  mounting and route shapes.
- **Google callback signature verification.** See the
  [Google Wallet](google-wallet.md) 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](lifecycle.md).
- **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.

```elixir
# config/config.exs
config :wallet_passes,
  event_handler: MyApp.WalletEventHandler
```

```elixir
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:

```elixir
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](https://developer.apple.com/library/archive/documentation/PassKit/Reference/PassKit_WebService/WebService.html).
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](https://developers.google.com/wallet/generic/use-cases/server-implementation/event-callback)
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](google-wallet.md) 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:

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

```elixir
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:

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

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:

```elixir
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 `apply`s the callback inside a `try/rescue/catch` block.

Because the router caller never `Task.await`s 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`:

```elixir
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:

```elixir
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

```elixir
@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 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:

```elixir
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.

```elixir
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.

```elixir
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`:

```elixir
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:

```elixir
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.

```elixir
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:

```elixir
: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](telemetry.md) 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:

```elixir
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](google-wallet.md)
   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](google-wallet.md) 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](#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 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](telemetry.md) 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

- **`WalletPasses.wallet_presence/1`** — returns
  `%{apple: boolean(), google: boolean() | nil}`. `:google` is `nil`
  when no callback has been recorded; `false` only when the most recent
  callback was `del`. Two database queries per call.
- **`WalletPasses.Schema.latest_google_callback/1`** — returns the
  single most recent `GoogleCallback` row, ordered by `id` descending.
  `nil` if no callback exists. Underlies `wallet_presence/1`'s `:google`
  field.
- **`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 `:ok` immediately;
  the handler runs asynchronously under
  `WalletPasses.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 omits `callbackOptions` from the Google
  class. See [Google Wallet](google-wallet.md).

### 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](getting-started.md) — for the full `:event_handler`
  configuration walkthrough and minimal `EventHandler` skeleton.
- [Apple Wallet](apple-wallet.md) — for `Apple.Router` mounting and the
  Web Service Protocol routes.
- [Google Wallet](google-wallet.md) — for `Google.Router` mounting,
  `:google_callback_url` setup, and `ECv2SigningOnly` verification.
- [Pass Lifecycle & Updates](lifecycle.md) — for issuer-driven state
  changes (`void_pass/1`, etc.) that complement user-driven events.
- [Telemetry](telemetry.md) — for the full catalog of dispatch and
  other telemetry events.