Pass Lifecycle & Updates

Copy Markdown View Source

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:

StatusMeaningGoogle stateReactivatable
:activeThe pass is current and usable. Default for new rows.ACTIVEn/a
:voidedThe pass has been revoked (refund, cancellation, fraud).INACTIVEyes
:expiredThe pass is past its validity window.EXPIREDyes
:completedThe pass has been used / redeemed.COMPLETEDyes

There are four transition functions on the top-level WalletPasses module — one per target status:

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:

  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 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 (ACTIVEINACTIVE 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 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 typeObject type
:event_ticketeventTicketObject
:boarding_passflightObject
:store_cardloyaltyObject
:couponofferObject
:genericgenericObject

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

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.

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

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

# 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
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.
  • 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:

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

  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:

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

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

Update without lifecycle change

Lower-level building blocks

Provider-side helper

Background sync (optional Oban add-on)

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 includes success_count and error_count.

See Telemetry for the full event reference and example attachments.

See also

  • Getting Started — setting up PassDataProvider and the rest of the config that underlies the lifecycle flow.
  • Apple Wallet — APNs internals, web service routes, and the .pkpass rebuild 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_removed callbacks.
  • Add-ons — Oban sync worker setup.
  • Telemetry — monitoring transitions in production.