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/1WalletPasses.expire_pass/1WalletPasses.complete_pass/1WalletPasses.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:
%{
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
# 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:
- Updates the
statuscolumn on whichever Apple/Google rows exist (in a single DB transaction). - Calls
Google.Api.update_object_state/3to PATCH the Google object'sstatefield — this is what causes Google Wallet to render the pass differently to users. - Calls
notify_apple_devices/1, which sends silent APNs pushes to every registered device so Apple Wallet pulls the updatedpass.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 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:
- Issuer: modify pass data in the DB.
- Issuer: send silent APNs push.
- Device: receives push, polls the web service URL.
- Issuer's server: rebuilds the
.pkpassviaPassDataProvider. - 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
stateto"INACTIVE". Done. The next time any device shows the pass, Google renders it as inactive. - For Apple: updates the DB row's
statuscolumn, then sends silent APNs pushes to every registered device. The device re-fetches via yourApple.Router, which callsPassDataProvider.build_pass_data/1. For the new content to reflect the status, your provider must readSchema.get_pass_status/1and surface the change in the rebuiltPassData(e.g., viaapply_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 discards10for 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 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.
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 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), 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:
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
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:
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
endWhen 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
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
endThree things to note:
- 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."
:not_foundoutcomes are common and not errors. A pass that was never saved to Google Wallet has no Google row, and voiding it succeeds withgoogle: :not_found.- Apple devices may not refetch immediately even if the push succeeds. See Troubleshooting.
Recipe 2: Expire a season pass at end of season
# 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)
endFor 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.
That avoids needing any server work at the boundary.
Recipe 3: Complete a redeemed coupon
def redeem_coupon(coupon_code) do
with {:ok, coupon} <- Coupons.redeem(coupon_code) do
{:ok, _} = WalletPasses.complete_pass(coupon.serial_number)
{:ok, coupon}
end
endFor 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.
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.
# 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/3sends a full PATCH built fromPassData, not a state PATCH. The Google object'sstateis unchanged.notify_apple_devices/1doesn'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 yourPassDataProviderbefore 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:
# 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:
%{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 for setup details on the Oban dependency.
Recipe 5: Reactivate a voided pass
{: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:
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
endWhat'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/1followed byreactivate_pass/1followed byvoid_pass/1works 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
expireObjectREST method that causes their UI to actually remove the pass; the library does not call it (the library'sexpire_pass/1uses thestate = EXPIREDPATCH, 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.
PassDatahas noexpiration_datefield today, so the library doesn't emit Apple'sexpirationDateor Google'svalidTimeInterval. Issuer-driven expiry viaexpire_pass/1is the supported path. If you need OS-managed date-based expiry, you'd need to extend the builder; see 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/3yourself. - 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:
- Did the push succeed? Inspect the return of
notify_apple_devices/1—{:ok, {success_count, error_count}}. Ifsuccess_countis 0, no devices got the push. Check that you have device registrations for the serial (Schema.list_push_tokens_for_serial/1). - Is the test device online and on a network? Silent pushes are not delivered to offline devices; they get queued and may be discarded.
- Does your
PassDataProvideractually reflect the status? Withoutapply_status_decoration/2(or your own status-aware code), thePassDatareturned 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. - 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:
{: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:
{: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:
- Check the row:
WalletPasses.Schema.get_google_pass(serial). Ifobject_idisnil, the object never made it onto Google's servers. - Re-run
WalletPasses.google_save_url/3for the serial — this re-creates the object and writes theobject_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:
# 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 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:
Schema.get_pass_status(serial)returns{:ok, :active}.- Your
build_pass_data/1readsget_pass_status/1and branches on it. - 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/ GoogleINACTIVE.WalletPasses.expire_pass/1— transitions to:expired/ GoogleEXPIRED.WalletPasses.complete_pass/1— transitions to:completed/ GoogleCOMPLETED.WalletPasses.reactivate_pass/1— transitions to:active/ GoogleACTIVE.
Update without lifecycle change
WalletPasses.update_google_pass/3— full PATCH of the Google object body. Accepts:class_id,:class_config,:translationsin 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 Googlestatefield. Lighter thanupdate_object/4(no full payload rebuild). Acceptspass_typeas an atom,object_idas a string,stateas"ACTIVE" | "INACTIVE" | "EXPIRED" | "COMPLETED".WalletPasses.Google.Api.update_object/4— full object PATCH used byupdate_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 toback_fieldsfor any non-:activestatus. No-op for:active. Call from inside your provider'sbuild_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. Acceptsserial_numbers(required, list of strings) andexclude_statuses(optional, list of string statuses to skip) as job args.
Type
@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 includessuccess_countanderror_count.
See Telemetry for the full event reference and example attachments.
See also
- Getting Started — setting up
PassDataProviderand the rest of the config that underlies the lifecycle flow. - Apple Wallet — APNs internals, web service routes,
and the
.pkpassrebuild path. - Google Wallet — object/class model and the full update API surface.
- Event Handling & Wallet Presence — detecting
whether devices actually saw your update via
on_pass_fetched/on_pass_removedcallbacks. - Add-ons — Oban sync worker setup.
- Telemetry — monitoring transitions in production.