# Pass Lifecycle & Updates
This guide explains how to take a pass from "active" through "voided",
"expired", or "completed" — and how to push content updates without changing
status at all. Apple Wallet and Google Wallet expose pass state to devices
through completely different mechanisms, but this library wraps both behind
four transition functions and a shared `lifecycle_result` map.
## Overview
### The four statuses
`wallet_passes` models every pass with one of four lifecycle statuses,
persisted in the `status` column on both the `wallet_passes_apple` and
`wallet_passes_google` rows:
| Status | Meaning | Google `state` | Reactivatable |
|--------------|----------------------------------------------------------|----------------|---------------|
| `:active` | The pass is current and usable. Default for new rows. | `ACTIVE` | n/a |
| `:voided` | The pass has been revoked (refund, cancellation, fraud). | `INACTIVE` | yes |
| `:expired` | The pass is past its validity window. | `EXPIRED` | yes |
| `:completed` | The pass has been used / redeemed. | `COMPLETED` | yes |
There are four transition functions on the top-level `WalletPasses` module —
one per target status:
- `WalletPasses.void_pass/1`
- `WalletPasses.expire_pass/1`
- `WalletPasses.complete_pass/1`
- `WalletPasses.reactivate_pass/1` (back to `:active`)
Each one updates the DB, patches Google's `state`, and pushes Apple devices
so they re-fetch the pass. All four are idempotent: calling `void_pass/1` on
an already-voided pass succeeds and re-issues the remote effects.
### Why a `lifecycle_result` instead of `:ok`
Each transition returns `{:ok, lifecycle_result()}` where the result map
tells you exactly what happened on each platform:
```elixir
%{
status: :voided,
apple: :ok | :not_found,
google: :ok | :not_found | {:error, term()}
}
```
The DB write is the source of truth. If Google's API returns 500 while
voiding, the DB row stays `:voided` and the result reports
`google: {:error, {500, body}}`. **The DB is never rolled back when remote
calls fail** — your retry logic decides whether to re-issue the call, and
the next attempt will see the correct local status.
`{:error, :not_found}` is returned only when **neither** an Apple nor a
Google row exists for the serial. If one platform has a row and the other
doesn't, the transition succeeds for the platform that has one and reports
`:not_found` for the platform that doesn't.
## Quick Start
```elixir
# Void a pass — refunded ticket, cancelled membership, etc.
{:ok, %{status: :voided, apple: :ok, google: :ok}} =
WalletPasses.void_pass("TICKET-42")
# Mark expired — your event window closed, subscription ended.
{:ok, %{status: :expired}} = WalletPasses.expire_pass("TICKET-42")
# Mark completed — pass was scanned and used.
{:ok, %{status: :completed}} = WalletPasses.complete_pass("TICKET-42")
# Reactivate — undo a void/expire/completion.
{:ok, %{status: :active}} = WalletPasses.reactivate_pass("TICKET-42")
```
Each call:
1. Updates the `status` column on whichever Apple/Google rows exist (in a
single DB transaction).
2. Calls `Google.Api.update_object_state/3` to PATCH the Google object's
`state` field — this is what causes Google Wallet to render the pass
differently to users.
3. Calls `notify_apple_devices/1`, which sends silent APNs pushes to every
registered device so Apple Wallet pulls the updated `pass.json`.
If you also want the pass content to *look* different in a non-active state
(grey ribbon, "VOIDED" stamp, status text on the back), see
[Surfacing status visually](#surfacing-status-visually) below.
## Concepts
Apple and Google deliver pass updates to devices through fundamentally
different mechanisms. Understanding both makes the rest of this guide make
sense.
### Apple: device-side fetch, server-side push prompt
The `.pkpass` bundle is downloaded once when the user adds the pass.
After that, Apple Wallet on the device polls your **web service URL** (the
one you configured as `:apple_web_service_url`) for changes — but not
constantly. The poll frequency is governed by iOS and is essentially "when
iOS feels like it" unless you give it a nudge.
The nudge is a **silent APNs push notification**. Your server sends an
empty-payload push to each device's APNs token, which tells iOS Wallet
"this serial has changed, go check." iOS Wallet then makes a `GET
/v1/passes/<passTypeId>/<serial>` request to your `Apple.Router`, which
calls your `PassDataProvider`, rebuilds the `.pkpass`, and ships it back.
The device replaces its local copy.
The whole update flow is therefore:
1. Issuer: modify pass data in the DB.
2. Issuer: send silent APNs push.
3. Device: receives push, polls the web service URL.
4. Issuer's server: rebuilds the `.pkpass` via `PassDataProvider`.
5. Device: replaces its local pass with the new bundle.
Without step 2 (the push), the device updates on its own schedule, which
can be hours to days. The push is the difference between "immediate" and
"eventual."
### Google: server-side render
Google Wallet works the other way around. The pass *object* lives on
Google's servers. Every time a user's device displays the pass, Google
renders it server-side from the current object state and ships the
rendered result to the device. There is no per-device cache the issuer
has to invalidate — when you PATCH the object on Google's servers, the
next time any device shows the pass it sees the new content.
The state transition (`ACTIVE` → `INACTIVE` etc.) is a single PATCH to the
object's `state` field. Google's UI renders inactive states with a visual
indicator (greyed out, "Inactive" stamp) automatically — you don't need to
modify field content for the state change to be visible.
For richer content updates (changed seat, new venue, updated label text),
you PATCH the full object via `Google.Api.update_object/4` or
`WalletPasses.update_google_pass/3`. Same delivery mechanism: next time
the user's device renders the pass, it sees the new fields.
### One transition, two delivery paths
When you call `WalletPasses.void_pass("TICKET-42")`, the library:
- **For Google**: PATCHes the object's `state` to `"INACTIVE"`. Done.
The next time any device shows the pass, Google renders it as inactive.
- **For Apple**: updates the DB row's `status` column, then sends silent
APNs pushes to every registered device. The device re-fetches via your
`Apple.Router`, which calls `PassDataProvider.build_pass_data/1`. **For
the new content to reflect the status**, your provider must read
`Schema.get_pass_status/1` and surface the change in the rebuilt
`PassData` (e.g., via `apply_status_decoration/2`).
This asymmetry — Google has built-in state, Apple needs you to render the
state into pass content — is the single most important thing to understand
about lifecycle management in this library. The next two sections expand
on each platform in detail.
## How It Works: Apple
Apple's pass format doesn't have a top-level "status" field. The closest
native concept is `expirationDate`, which the OS uses to grey out expired
passes automatically (more on that below). For every other state
(`:voided`, `:completed`, and issuer-driven `:expired`), the library
relies on **content** changes — you surface the state by modifying what's
in `pass.json` itself.
### The silent APNs push
`notify_apple_devices/1` looks up every device registration row for the
serial via `Schema.list_push_tokens_for_serial/1`, then sends an empty
HTTP/2 POST to `https://api.push.apple.com/3/device/<token>` for each
token. Headers:
- `apns-topic: <pass_type_id>` — the pass type ID, not your app's bundle ID.
- `apns-push-type: background` — silent push, no UI.
- `apns-priority: 5` — "send when convenient" (Apple discards `10` for
passes).
The HTTP/2 client authenticates with your pass-type certificate
(`:apple_pass_type_cert` + `:apple_pass_type_key`). This is the same cert
that signs the `.pkpass` bundles — Apple uses cert presence to authorize
the push.
The return value is `{:ok, {success_count, error_count}}` — counts of how
many tokens accepted the push and how many failed. Failures are
*individually* counted but not individually reported; check telemetry
(`[:wallet_passes, :apple, :push, :stop]`) for aggregate numbers, and
your `EventHandler.on_pass_removed/3` for per-device unreachability
signals.
Crucially: a successful APNs push does NOT mean the device updated. It
means the push was queued for delivery. The device may be offline, the
user may have disabled background app refresh, iOS may simply not poll
your web service for hours. There is no "did the user see this" signal
on Apple — see [Event Handling & Wallet Presence](event-handling.md) for
the closest approximation (`on_pass_fetched`).
### The device re-fetch
When the device does poll, it hits `Apple.Router`'s `GET
/v1/passes/:passTypeId/:serialNumber` route. That route calls
`PassDataProvider.build_pass_data/1`, builds a fresh `.pkpass` via
`Apple.Builder.build_pkpass/4`, and returns it.
**This is where status becomes visible to the user**, but only if your
provider actually reads the status from the DB and reflects it in the
returned `PassData`. The provider doesn't see the status automatically —
the library doesn't inject anything into your `PassData` for you. See
[Surfacing status visually](#surfacing-status-visually).
### `expirationDate` vs `expire_pass/1`
Apple's PassKit format supports a top-level `expirationDate` field in
`pass.json` — an ISO 8601 timestamp. After that moment, iOS Wallet
automatically renders the pass with a "EXPIRED" visual treatment. No
server interaction needed; the device clock drives the change.
This is **OS-managed, date-based expiry**. It's the right tool for:
- Concert tickets where the event has a known end time.
- Subscription passes with a fixed renewal date.
- Coupons with a printed expiration.
`expire_pass/1` is **issuer-driven, immediate expiry**. It's the right
tool for:
- A refund that retroactively expires a not-yet-used ticket.
- A revoked subscription before its renewal date.
- A promotion ended early.
- Anything where the issuer — not the calendar — decides the pass is
done.
The library does not currently emit `expirationDate` from `PassData`
automatically (there's no `expiration_date` field on the struct yet). If
you need OS-managed expiry today, you'd set it via a custom builder layer
or wait for the field to land. For everything else, `expire_pass/1` plus
[status decoration](#surfacing-status-visually) covers the use case.
The two are not mutually exclusive: a pass can have a future
`expirationDate` *and* be `expire_pass/1`-d early. The library's status
column is independent of the OS-managed date.
## How It Works: Google
### `Google.Api.update_object_state/3`
Every lifecycle transition for Google reduces to one PATCH:
```
PATCH /walletobjects/v1/<objectType>/<objectId>
{
"state": "INACTIVE"
}
```
That's it. The library's `update_object_state/3` issues exactly this
request — it does NOT rebuild the full pass payload, does NOT require
`PassData`, does NOT touch the class. It's a single-field state mutation
on the object identified by `object_id` (stored in the
`wallet_passes_google.object_id` column).
The pass type determines the URL path:
| Pass type | Object type |
|------------------|--------------------|
| `:event_ticket` | `eventTicketObject`|
| `:boarding_pass` | `flightObject` |
| `:store_card` | `loyaltyObject` |
| `:coupon` | `offerObject` |
| `:generic` | `genericObject` |
An internal `transition/2` helper (private; the four public transition
functions wrap it) determines the type by reading the `pass_type` column
on the `wallet_passes_google` row. If the row was created on an older
version of the library and the column is `nil`, the library calls your
`PassDataProvider.build_pass_data/1` for the serial to learn the pass
type, then **writes the value back** into the row so future transitions
skip the lookup. This back-fill happens transparently — your application
code doesn't need to do anything to opt in.
### Why server-side render means immediate updates
Because Google renders the pass server-side per request, the state change
takes effect the moment the PATCH returns 200. The next time *any* user's
device displays the pass — typically within seconds to minutes of opening
Google Wallet — they see the new state. There is no per-device push to
schedule, no cache to invalidate, no equivalent of Apple's silent APNs.
Google does also send save/delete callbacks to your server when users
add/remove passes (see [Event Handling & Wallet Presence](event-handling.md)),
but those are signal-only — they don't gate update delivery.
### Content updates without state change
If you want to change pass content (a seat assignment, a label, a venue
name) without transitioning the lifecycle, use
`WalletPasses.update_google_pass/3`. That sends a full PATCH of the
object body (built fresh from your `PassData`), not just the `state`
field:
```elixir
WalletPasses.update_google_pass(updated_pass_data, google_visual)
```
For Apple, the equivalent is to update your provider's data source and
then call `notify_apple_devices/1`. See
[Updating content without changing status](#recipe-4-updating-content-without-changing-status)
below.
## Surfacing status visually
The library updates `status` columns and Google object state, but it does
not modify pass *content* — your `PassDataProvider.build_pass_data/1`
remains in charge of what each pass looks like. To make a voided or
expired pass look different on Apple, your provider must read the current
status and adjust the `PassData` it returns.
The library ships a one-line helper for the common case.
### `PassDataProvider.apply_status_decoration/2`
Prepends a `{"status", "Status", LABEL}` row to `back_fields` for any
non-`:active` status. The label is the uppercased status name (`"VOIDED"`,
`"EXPIRED"`, `"COMPLETED"`). For `:active`, it's a no-op.
Recommended pattern inside `build_pass_data/1`:
```elixir
defmodule MyApp.WalletPassProvider do
@behaviour WalletPasses.PassDataProvider
alias WalletPasses.PassDataProvider
alias WalletPasses.Schema
@impl true
def build_pass_data(serial_number) do
with {:ok, record} <- fetch_ticket(serial_number) do
pass_data =
%WalletPasses.PassData{
serial_number: serial_number,
event_name: record.event_name,
# ... your usual fields
}
|> maybe_apply_status(serial_number)
{:ok, %{pass_data: pass_data, apple: apple_visual(), google: google_visual()}}
end
end
defp maybe_apply_status(pass_data, serial_number) do
case Schema.get_pass_status(serial_number) do
{:ok, status} -> PassDataProvider.apply_status_decoration(pass_data, status)
_ -> pass_data
end
end
end
```
When the provider is called from Apple's web service callback after a
silent push, the new status row shows up on the back of the pass. For
non-active passes, your users see a "Status: VOIDED" entry alongside
their other back-field info.
### Going further — custom decoration
`apply_status_decoration/2` is intentionally minimal: one back-field row.
If you want a stronger visual signal — a "VOIDED" stamp baked into the
strip image, a different background color for expired passes, a barcode
that says "INVALID" — read the status yourself and branch on it inside
`build_pass_data/1`. The library doesn't care what you put in the
`PassData`; Apple gets exactly what your provider returns.
### `Schema.get_pass_status/1` return shapes
`get_pass_status/1` returns one of:
- `{:ok, :active | :voided | :expired | :completed}` — both Apple and
Google rows agree, or only one exists.
- `{:diverged, %{apple: status, google: status}}` — both rows exist but
disagree (can happen if you set the column directly bypassing the
transition functions).
- `{:error, :not_found}` — neither row exists.
If you call this from inside `build_pass_data/1` (which is reached only
via Apple's web service callback for an existing pass), you can typically
assume one of the rows exists. Handle `:diverged` defensively if your
code path could plausibly hit it; in practice, with only the four
transition functions modifying status, it shouldn't.
## Recipes
### Recipe 1: Void a refunded ticket
```elixir
def refund_ticket(ticket_id) do
with {:ok, ticket} <- Tickets.refund(ticket_id) do
{:ok, lifecycle} = WalletPasses.void_pass(ticket.serial_number)
log_remote_outcomes(ticket.serial_number, lifecycle)
{:ok, ticket}
end
end
defp log_remote_outcomes(serial, %{apple: apple, google: google}) do
if match?({:error, _}, google) do
Logger.warning("Google void failed for #{serial}: #{inspect(google)}; will retry")
end
if apple == :not_found and google == :not_found do
Logger.info("No wallet rows for #{serial} — pass was never saved")
end
end
```
Three things to note:
1. The DB is updated before either remote call runs, so even if both
remotes fail, the local state is consistent and a retry will pick up
from "already voided locally, just re-issue remotes."
2. `:not_found` outcomes are common and not errors. A pass that was
never saved to Google Wallet has no Google row, and voiding it
succeeds with `google: :not_found`.
3. Apple devices may not refetch immediately even if the push succeeds.
See [Troubleshooting](#troubleshooting).
### Recipe 2: Expire a season pass at end of season
```elixir
# Mass expiration: end-of-season job runs through all passes for the
# event and marks them expired.
def expire_season(season_id) do
Tickets.list_serials_for_season(season_id)
|> Enum.each(fn serial ->
case WalletPasses.expire_pass(serial) do
{:ok, _} -> :ok
{:error, :not_found} -> :ok
end
end)
end
```
For an issuer-driven mass expiry, this approach makes sense — each pass
gets its own silent push, its own DB write, its own Google PATCH.
If your "expiration" is purely calendar-based (every pass expires at
midnight on Dec 31 regardless of issuer action), consider Apple's native
`expirationDate` field instead — see
[When to use `expirationDate` vs `expire_pass/1`](#expirationdate-vs-expire_pass1).
That avoids needing any server work at the boundary.
### Recipe 3: Complete a redeemed coupon
```elixir
def redeem_coupon(coupon_code) do
with {:ok, coupon} <- Coupons.redeem(coupon_code) do
{:ok, _} = WalletPasses.complete_pass(coupon.serial_number)
{:ok, coupon}
end
end
```
For coupons that should be visibly "used" rather than removed, completion
is the right call. The user keeps the pass in their wallet (useful as a
receipt / proof of purchase) but it's clearly marked.
For coupons that should disappear immediately on redemption, there is
no library-managed delete — the library does not call Google's
`expireObject` REST method (which would truly remove the pass) and Apple
has no equivalent at all. The best approximation is `complete_pass/1` or
`expire_pass/1`; see [What's NOT Covered](#whats-not-covered).
### Recipe 4: Updating content without changing status
Sometimes you just want to change a pass — fix a typo, update a seat,
change a venue — without transitioning lifecycle.
```elixir
# 1. Modify your data source.
Tickets.update_seat(serial, "Section B, Row 14, Seat 7")
# 2. Push Google immediately.
{:ok, _} = WalletPasses.update_google_pass(updated_pass_data, google_visual)
# 3. Push Apple to fetch the new pass.json.
{:ok, {_success, _err}} = WalletPasses.notify_apple_devices(serial)
```
Three details:
- `update_google_pass/3` sends a full PATCH built from `PassData`, not a
state PATCH. The Google object's `state` is unchanged.
- `notify_apple_devices/1` doesn't update anything by itself — it just
prompts the device to refetch from your web service URL. The data
update must already be visible to your `PassDataProvider` before you
push, or the device will pull stale content.
- The order matters slightly: update your DB first, then push. If you
push before updating the DB, the device might race and refetch the old
content.
#### Background bulk updates via Oban
For periodic refreshes of every pass — say, nightly to keep field text in
sync with backend data — the optional `WalletPasses.Sync` module enqueues
an Oban job that rebuilds every Google object and pushes every Apple
device:
```elixir
# Sync specific passes
WalletPasses.Sync.sync(["SERIAL-1", "SERIAL-2"])
# Sync all passes in the database
WalletPasses.Sync.sync_all()
```
Requires `{:oban, "~> 2.18"}` in your deps. The worker
(`WalletPasses.Sync.Worker`) calls your `PassDataProvider` for each
serial, PATCHes the Google object via `Google.Api.update_object/4`, and
sends Apple silent pushes in bulk.
`sync_all/0` walks every row in `wallet_passes_apple` and
`wallet_passes_google` — useful for "I changed my pass template" rollouts,
expensive for daily jobs. For daily jobs, scope by serial.
The worker accepts an `exclude_statuses` job arg to skip non-active
passes:
```elixir
%{serial_numbers: ["S-1", "S-2"], exclude_statuses: ["voided", "expired", "completed"]}
|> WalletPasses.Sync.Worker.new()
|> Oban.insert()
```
When set, the worker skips rebuilds for any pass whose Google row's
status matches, and also skips Apple pushes for those serials. Use this
to avoid re-pushing already-terminal passes when a bulk sync runs.
See [Add-ons](addons.md) for setup details on the Oban dependency.
### Recipe 5: Reactivate a voided pass
```elixir
{:ok, %{status: :active}} = WalletPasses.reactivate_pass("TICKET-42")
```
This sets the DB column back to `"active"`, PATCHes Google's `state` to
`"ACTIVE"`, and silent-pushes Apple devices to refetch. Your provider
should see the active status next time and return un-decorated pass
content.
The library does NOT enforce transition validity — you can reactivate
from any state, including `:completed` and `:expired`. If you want a
state machine that forbids "completed → active", wrap the calls in your
own policy layer:
```elixir
def safe_reactivate(serial) do
case WalletPasses.Schema.get_pass_status(serial) do
{:ok, :voided} -> WalletPasses.reactivate_pass(serial)
{:ok, :expired} -> {:error, :cannot_reactivate_expired}
{:ok, :completed} -> {:error, :cannot_reactivate_completed}
{:ok, :active} -> {:ok, :already_active}
other -> other
end
end
```
## What's NOT Covered
These are intentionally out of scope for the library; you'll need other
tools or your own code if you need them.
- **State machine enforcement.** The library doesn't forbid any
transition. `complete_pass/1` followed by `reactivate_pass/1` followed
by `void_pass/1` works fine. If you need a state graph (no completed →
active, no expired → voided, etc.), wrap the calls in your own policy
module.
- **Removing passes from devices.** Google has an `expireObject` REST
method that causes their UI to actually remove the pass; the library
does not call it (the library's `expire_pass/1` uses the `state =
EXPIRED` PATCH, which dims the pass but keeps it in the wallet). To
fully remove a pass, you'd need to call Google's API directly. There
is no equivalent on Apple — once a pass is added, only the user can
remove it.
- **Automatic OS-managed expiry.** `PassData` has no `expiration_date`
field today, so the library doesn't emit Apple's `expirationDate` or
Google's `validTimeInterval`. Issuer-driven expiry via
`expire_pass/1` is the supported path. If you need OS-managed
date-based expiry, you'd need to extend the builder; see
[How It Works: Apple](#how-it-works-apple).
- **Transactional remote rollbacks.** When the DB write succeeds but
Google's API returns 500, the DB stays updated. The library prioritizes
local correctness over remote consistency — your retry logic owns the
reconciliation. If you need two-phase commit semantics across Wallet
APIs, the library is the wrong layer.
- **Per-platform-only transitions.** Every transition function targets
both platforms. If you want to void a pass on Apple but keep it active
on Google (rare and probably surprising to users), you'd update the
schema rows directly and call `Google.Api.update_object_state/3`
yourself.
- **Class-level lifecycle.** Statuses live on the *object* (per-pass
instance), not the *class* (template). There is no "void the class"
operation; class updates are content updates via
`Google.Api.ensure_class/2`.
## Troubleshooting
### "I voided the pass but the device still shows it active"
Most common cause on Apple: the silent APNs push hasn't been delivered
yet, or the device's iOS hasn't polled your web service URL yet. Even
when the push succeeds, iOS picks its own fetch time — sometimes minutes,
sometimes hours.
Things to check:
1. **Did the push succeed?** Inspect the return of `notify_apple_devices/1`
— `{:ok, {success_count, error_count}}`. If `success_count` is 0, no
devices got the push. Check that you have device registrations for
the serial (`Schema.list_push_tokens_for_serial/1`).
2. **Is the test device online and on a network?** Silent pushes are not
delivered to offline devices; they get queued and may be discarded.
3. **Does your `PassDataProvider` actually reflect the status?** Without
`apply_status_decoration/2` (or your own status-aware code), the
`PassData` returned to the web service callback has identical content
regardless of status — the device fetches a "new" pass that looks
identical to the old one. The status DB column is correct, but the
user can't tell. Add the decoration.
4. **Force a refetch.** On the test device, swipe the pass and tap the
"..." menu → "Pass Details" → and pull-to-refresh, or remove and
re-add the pass. This bypasses iOS's polling cadence.
On Google: a state PATCH takes effect server-side immediately. If a
device still shows the pass as active after a successful
`update_object_state/3`, the user's Google Wallet app may have cached
the rendered view — closing and reopening Wallet usually resolves it.
### "The DB shows voided but the result includes `google: {:error, ...}`"
Working as designed. The DB write succeeded, the Google PATCH failed.
Retry the void:
```elixir
{:ok, %{google: {:error, _}}} = WalletPasses.void_pass(serial)
# Later, possibly after fixing whatever caused the 500:
{:ok, %{google: :ok}} = WalletPasses.void_pass(serial)
```
Because the transitions are idempotent and the DB is already in the
right state, re-calling the transition just re-issues the remote PATCH
and re-pushes Apple. No special "retry just Google" function is needed.
For ad-hoc retries of only the Google side without touching Apple, call
`Google.Api.update_object_state/3` directly:
```elixir
{:ok, _} = WalletPasses.Google.Api.update_object_state(
:event_ticket,
google_object_id,
"INACTIVE"
)
```
### "I see `google: :not_found` but the pass was saved to Google"
This means the `wallet_passes_google` row's `object_id` is `nil` — the
object was never created on Google's servers, or the creation succeeded
but `Schema.update_google_object_id/2` failed.
The transition flow skips Google when `object_id` is `nil` because
there's no remote object to PATCH. To recover:
1. Check the row: `WalletPasses.Schema.get_google_pass(serial)`. If
`object_id` is `nil`, the object never made it onto Google's
servers.
2. Re-run `WalletPasses.google_save_url/3` for the serial — this
re-creates the object and writes the `object_id`. Then re-issue the
transition.
If the row itself doesn't exist (`get_google_pass/1` returns `nil`),
the pass was never registered with the library on the Google side at
all — there's no way for `update_object_state/3` to know what to
patch.
### "Old class fields are showing up after I updated the class"
The library caches class IDs across process lifetime to avoid redundant
`create_or_update_class` calls — once a class is created in the current
VM, it won't be re-PATCHed unless you explicitly call
`Google.Api.create_or_update_class/2` with the new config.
For routine class updates (issuer name change, new venue), call
`create_or_update_class` directly after updating your class config in
code — `ensure_class/2` won't repatch:
```elixir
# Force a fresh class PATCH:
{:ok, _} = WalletPasses.Google.Api.create_or_update_class(
%{id: "my_class", issuer_name: "Updated Name", event_name: "Concert"},
:event_ticket
)
```
See [Google Wallet](google-wallet.md) for the class/object distinction
and how `ensure_class` caches.
### "Batch transitions are slow"
Each transition issues one HTTP call to Google and N HTTP/2 pushes to
Apple (one per registered device). For a thousand passes, that adds up.
Options: enqueue the work via the Oban sync worker
(`WalletPasses.Sync.sync/1`) off the request path; spawn transitions
concurrently with `Task.async_stream/2` (each call has its own DB
transaction and HTTP calls); or bypass the transition wrapper and call
`Schema.set_pass_status/2` + `Google.Api.update_object_state/3` directly
when you know no Apple devices are registered.
### "`reactivate_pass/1` succeeded but the pass still looks voided on Apple"
Same root cause as the first troubleshooting item: your
`PassDataProvider` is still reading the old status, or your provider
isn't decorating based on status at all, or the device hasn't refetched
yet. Verify:
1. `Schema.get_pass_status(serial)` returns `{:ok, :active}`.
2. Your `build_pass_data/1` reads `get_pass_status/1` and branches on
it.
3. The device has received the silent push and refetched.
If all three are true and the pass still looks voided, your provider
likely has its own cached pass data that didn't refresh. Add logging in
`build_pass_data/1` to confirm what it returns.
### "The pass_type column is nil and transitions are failing"
Old rows created before the `pass_type` column was added may have `nil`
in that column. The library handles this automatically: on the first
transition, it calls your `PassDataProvider.build_pass_data/1` to learn
the pass type and back-fills the column. Subsequent transitions skip the
lookup.
If your provider returns `{:error, _}` or a bundle without `pass_type`,
the transition's Google side returns
`{:error, {:pass_type_lookup_failed, _reason}}` or
`{:error, :pass_type_missing_from_provider}`. Fix the provider to return
a valid `pass_type` atom and retry the transition — the back-fill will
succeed.
## API Reference
### Top-level transition functions
All return `{:ok, lifecycle_result()} | {:error, :not_found}`.
- `WalletPasses.void_pass/1` — transitions to `:voided` / Google `INACTIVE`.
- `WalletPasses.expire_pass/1` — transitions to `:expired` / Google `EXPIRED`.
- `WalletPasses.complete_pass/1` — transitions to `:completed` / Google `COMPLETED`.
- `WalletPasses.reactivate_pass/1` — transitions to `:active` / Google `ACTIVE`.
### Update without lifecycle change
- `WalletPasses.update_google_pass/3` — full PATCH of the Google object body.
Accepts `:class_id`, `:class_config`, `:translations` in opts.
- `WalletPasses.notify_apple_devices/1` — sends silent APNs pushes for every
registered device on the serial. Returns `{:ok, {success_count, error_count}}`.
### Lower-level building blocks
- `WalletPasses.Schema.set_pass_status/2` — write the status column
directly. Use only if you need to bypass the transition functions
(e.g., to set the DB without firing remote effects). Returns
`{:ok, %{apple: :ok | :not_found, google: :ok | :not_found}}`.
- `WalletPasses.Schema.get_pass_status/1` — read the current status.
Returns `{:ok, status}`, `{:diverged, %{apple: _, google: _}}`, or
`{:error, :not_found}`.
- `WalletPasses.Google.Api.update_object_state/3` — PATCH only the
Google `state` field. Lighter than `update_object/4` (no full payload
rebuild). Accepts `pass_type` as an atom, `object_id` as a string,
`state` as `"ACTIVE" | "INACTIVE" | "EXPIRED" | "COMPLETED"`.
- `WalletPasses.Google.Api.update_object/4` — full object PATCH used by
`update_google_pass/3`.
- `WalletPasses.Apple.Push.notify_devices/1` — lower-level push helper
that takes a list of push tokens directly.
### Provider-side helper
- `WalletPasses.PassDataProvider.apply_status_decoration/2` — prepends a
`{"status", "Status", <UPPERCASED>}` row to `back_fields` for any
non-`:active` status. No-op for `:active`. Call from inside your
provider's `build_pass_data/1`.
### Background sync (optional Oban add-on)
- `WalletPasses.Sync.sync/1` — enqueues an Oban job to refresh a list of
serials.
- `WalletPasses.Sync.sync_all/0` — enqueues a job covering every row in
the wallet tables.
- `WalletPasses.Sync.Worker` — the Oban worker. Accepts `serial_numbers`
(required, list of strings) and `exclude_statuses` (optional, list of
string statuses to skip) as job args.
### Type
```elixir
@type lifecycle_result :: %{
status: :active | :voided | :expired | :completed,
apple: :ok | :not_found,
google: :ok | :not_found | {:error, term()}
}
```
### Telemetry events worth attaching for lifecycle work
- `[:wallet_passes, :google, :update_object_state, :start | :stop | :exception]` —
every state PATCH issued by a transition function.
- `[:wallet_passes, :apple, :push, :start | :stop]` — every batch of
silent pushes. Stop event includes `success_count` and `error_count`.
See [Telemetry](telemetry.md) for the full event reference and example
attachments.
## See also
- [Getting Started](getting-started.md) — setting up `PassDataProvider`
and the rest of the config that underlies the lifecycle flow.
- [Apple Wallet](apple-wallet.md) — APNs internals, web service routes,
and the `.pkpass` rebuild path.
- [Google Wallet](google-wallet.md) — object/class model and the full
update API surface.
- [Event Handling & Wallet Presence](event-handling.md) — detecting
whether devices actually saw your update via
`on_pass_fetched`/`on_pass_removed` callbacks.
- [Add-ons](addons.md) — Oban sync worker setup.
- [Telemetry](telemetry.md) — monitoring transitions in production.