wallet_passes ships two opt-in features behind optional dependencies:
LiveView preview components for rendering passes inside your app, and an
Oban-backed background sync worker for keeping passes in step with your
domain data. Neither is required to issue or update passes — they're
tools for development and operations.
Overview
- LiveView preview components —
apple_pass_preview/1andgoogle_pass_preview/1render a styled pass card from already-builtpass.json/ Google object JSON. - Oban
Sync.Worker— given a list of serial numbers, re-PATCHes each pass's Google object via yourPassDataProviderand sends silent APNs pushes to every registered Apple device.WalletPasses.Sync.sync/1andWalletPasses.Sync.sync_all/0are the convenience entry points.
The optional-deps pattern
In mix.exs, both extras are declared optional: true:
{:phoenix_live_view, "~> 1.0", optional: true},
{:oban, "~> 2.18", optional: true},optional: true means Hex resolves the dep when the consuming app
provides it, but won't pull it in automatically. The library wraps each
module in if Code.ensure_loaded?(Phoenix.LiveView) do ... end /
if Code.ensure_loaded?(Oban) do ... end, so
WalletPasses.Preview.Components, WalletPasses.Sync, and
WalletPasses.Sync.Worker are only compiled when the corresponding dep
is present.
To use either add-on, declare the dep in your own app:
defp deps do
[
{:wallet_passes, "~> 0.8"},
{:phoenix_live_view, "~> 1.0"}, # for preview components
{:oban, "~> 2.18"}, # for background sync
]
endCalling WalletPasses.Sync.sync(...) without Oban raises
UndefinedFunctionError — the module isn't defined. Same for the
preview components.
LiveView Preview Components
WalletPasses.Preview.Components exposes two function components that
take already-built pass JSON and render a styled card. They don't talk
to Apple or Google — they read the same data structures the library
produces.
Quick example
In a LiveView, build the JSON shapes the components consume and render:
import WalletPasses.Preview.Components
alias WalletPasses.{Apple, Google, QR}
def mount(_params, _session, socket) do
{:ok, pass_data, apple_visual, google_visual} = load_pass()
{:ok,
assign(socket,
apple_json: Apple.Builder.build_pass_json(pass_data, apple_visual, "tok"),
google_obj: Google.Api.build_pass_object(pass_data, google_visual),
qr_svg: QR.svg(pass_data.serial_number)
)}
end<div class="grid grid-cols-2 gap-6">
<.apple_pass_preview pass_json={@apple_json} qr_svg={@qr_svg} />
<.google_pass_preview pass_object={@google_obj} qr_svg={@qr_svg} />
</div>The components use Tailwind / DaisyUI class names. If your app uses neither, wrap them in your own styling — the markup carries no inline styles outside of the colors pulled from the pass itself.
Component assigns
apple_pass_preview/1 takes :pass_json (the map from
Apple.Builder.build_pass_json/3) and :qr_svg (inline SVG markup from
WalletPasses.QR.svg/1). google_pass_preview/1 takes :pass_object
(from Google.Api.build_pass_object/2) and :qr_svg. Both assigns are
required: true. Pass qr_svg: "" to omit the QR.
Which fields render
apple_pass_preview/1 detects the pass type by which structure key
(boardingPass, storeCard, coupon, generic, eventTicket) is
present and renders logoText, description, the first primaryFields
entry as the headline, all secondaryFields and auxiliaryFields as
labeled columns, backgroundColor / foregroundColor / labelColor as
inline styles, the QR SVG, barcode.message, and a "Back of pass"
card listing backFields if any.
google_pass_preview/1 mirrors that: header.defaultValue.value and a
derived type label, the holder name (ticketHolderName /
passengerName / accountName) as the headline, each textModulesData
entry as a labeled row, hexBackgroundColor, the QR SVG, and
barcode.value.
Both components are approximations of how Apple Wallet and Google Wallet render passes — they're for layout and content review, not pixel-accurate previews.
When to use them
Development. The bundled sandbox at dev/wallet_passes_dev/ uses
these components for live previews while editing pass data. Internal
tools — pass designers, fixture browsers, QA checklists — are the
sweet spot.
Customer-facing previews. Stable enough for an "is this right?" confirmation step before a save. Wrap them in your own card chrome and they pass for production UI.
Not for validating the bundle. A successful render says nothing
about whether the .pkpass will pass signature verification or whether
the Google object will round-trip through the Wallet API. For that,
inspect the actual artefact (see
Local Development).
Background Sync (Oban)
WalletPasses.Sync.Worker is an Oban.Worker that re-issues passes
across both platforms in one job. WalletPasses.Sync exposes two entry
points: sync/1 for a specific list of serials, and sync_all/0 for
every pass in the database.
Quick example
After adding {:oban, "~> 2.18"} to your deps, configure a
:wallet_passes_sync queue:
# config/config.exs
config :my_app, Oban,
repo: MyApp.Repo,
queues: [wallet_passes_sync: 5]Then enqueue work:
WalletPasses.Sync.sync(["TICKET-001", "TICKET-002"])
WalletPasses.Sync.sync_all()Both return {:ok, %Oban.Job{}} — the work happens inside the worker.
What the worker does
For each serial in args["serial_numbers"]:
- Looks up the Google pass row, calls your
PassDataProvider.build_pass_data/1for freshPassDataand aGoogle.Visual, and PATCHes the existing Google object viaGoogle.Api.update_object/3(re-keys it; doesn't recreate). Failures are logged and counted into:google_err. - Collects every Apple push token registered for the surviving serials,
dedupes across serials, and fires a single bulk
Apple.Push.notify_devices/1call.
The job returns
{:ok, %{google_ok: N, google_err: N, apple_ok: N, apple_err: N}}.
The worker is unique: [states: [:available, :scheduled, :executing]]
on :wallet_passes_sync with max_attempts: 1. Back-to-back enqueues
of the same args coalesce; a failed sync is not retried (re-enqueue
if you need to).
exclude_statuses
The worker accepts an optional exclude_statuses arg listing
lifecycle statuses to skip:
%{
serial_numbers: ["TICKET-001", "VOIDED-002", "TICKET-003"],
exclude_statuses: ["voided", "expired"]
}
|> WalletPasses.Sync.Worker.new()
|> Oban.insert()Values are strings — Oban serializes job args through JSON, which loses
atoms. A pass whose Google status matches any excluded value is
dropped from both the Google PATCH set and the Apple push set: its
tokens are filtered out before the bulk push fires.
The four supported statuses are :active, :voided, :expired, and
:completed. The sync/1 and sync_all/0 shortcuts don't accept
exclude_statuses — call Worker.new/1 directly when you need it.
Scheduling cadence
There's no built-in scheduler — when and how often to sync is up to your app. Two common patterns:
On-change syncs. When data a pass renders changes (seat assignment,
event time, loyalty balance), enqueue a sync for the affected serials.
The unique constraint coalesces bursts:
def update_seat(serial, new_seat) do
{:ok, _} = update_in_db(serial, new_seat)
WalletPasses.Sync.sync([serial])
endPeriodic via Oban Cron. For passes that drift on a schedule (e.g.
nightly tier recalculation), use Oban.Plugins.Cron:
defmodule MyApp.NightlySync do
use Oban.Worker, queue: :default
def perform(_job) do
WalletPasses.Sync.Worker.new(%{
serial_numbers: MyApp.list_active_serials(),
exclude_statuses: ["voided", "expired", "completed"]
})
|> Oban.insert()
end
endAvoid blanket sync_all/0 on tight schedules. Every sync calls your
PassDataProvider and hits the Google Wallet API once per pass. With
thousands of passes, batch by domain key and stagger the work — the
Google Wallet API has per-issuer rate limits, and the worker has no
built-in throttle.
API Reference
Preview components
WalletPasses.Preview.Components.apple_pass_preview/1— required assigns::pass_json(map),:qr_svg(string).WalletPasses.Preview.Components.google_pass_preview/1— required assigns::pass_object(map),:qr_svg(string).WalletPasses.Apple.Builder.build_pass_json/3— producespass_json.WalletPasses.Google.Api.build_pass_object/2— producespass_object.WalletPasses.QR.svg/1— producesqr_svgfrom the barcode string.
Background sync
WalletPasses.Sync.sync/1—(serials :: [String.t()]) :: {:ok, Oban.Job.t()}.WalletPasses.Sync.sync_all/0— enqueues a job covering every persisted Apple/Google serial (deduped).WalletPasses.Sync.Worker—use Oban.Worker. Itsnew/1accepts:serial_numbers(required, list of strings) and:exclude_statuses(optional, list of"active" | "voided" | "expired" | "completed").perform/1returns{:ok, %{google_ok: integer, google_err: integer, apple_ok: integer, apple_err: integer}}.
Queue: :wallet_passes_sync. max_attempts: 1. Unique across
:available, :scheduled, and :executing states.
Related guides
- Getting Started — installing the library and
configuring the
PassDataProviderthe sync worker depends on. - Pass Lifecycle & Updates — the four pass statuses
that
exclude_statusesfilters on, and the rationale for skipping inactive passes during bulk sync. - Local Development — the bundled dev sandbox that demonstrates the preview components against live edits.