This guide covers everything wallet_passes does for Apple Wallet: assembling the .pkpass bundle, signing it, serving it over Apple's Web Service Protocol, and pushing devices when content changes. It opens with a Concepts section for readers new to PassKit, then moves to library-specific behaviour that experienced wallet developers can jump to directly.

For cert chain setup and a first-pass walkthrough, see Getting Started. For shipping passes in multiple languages, see Localization. For shipping Google Wallet alongside Apple, see Google Wallet.

Overview

What this library does for Apple

  • Builds a signed .pkpass ZIP from a PassData struct + an Apple.Visual struct, returning the binary in-memory (no temp files).
  • Generates an Apple-required authenticationToken per serial number and persists it so the Web Service Protocol can validate device requests.
  • Signs the bundle with a pure-Erlang PKCS#7 implementation — no openssl binary on PATH, no temp directories, no shell-out.
  • Mounts a Plug.Router (WalletPasses.Apple.Router) implementing all four Apple Web Service Protocol endpoints (device registration, unregistration, pass fetch, and serial enumeration).
  • Sends silent APNs background pushes over HTTP/2 with client-cert authentication to trigger device re-fetches.
  • Tracks every device registration in a wallet_pass_device_registrations table so push delivery is targeted, not broadcast.

What this library does NOT do for Apple

  • Generate the certificates. You bring a pass type ID certificate, its signing key, and the WWDR intermediate. See Getting Started for how to obtain them.
  • Render the visual pass. It writes pass.json and assembles asset files. The OS renders the pass on-device.
  • Resize or convert images. Whatever you put on disk goes into the ZIP verbatim. Producing the right @2x/@3x variants is your job — see Theming & Visual Design for dimensions.
  • Distribute the .pkpass. You serve the binary over whatever HTTP endpoint suits your app (email attachment, AirDrop-friendly URL, in-app download). The library hands you the bytes; you control delivery.

Quick Start

A minimal Apple Wallet pass with credentials configured looks like this:

alias WalletPasses.{Apple, PassData}

pass_data = %PassData{
  serial_number: "ticket-001",
  pass_type: :event_ticket,
  description: "Summer Music Festival ticket",
  organization_name: "Festival Co",
  event_name: "Summer Music Festival",
  primary_fields: [{"name", "Name", "Jane Doe"}],
  secondary_fields: [{"gate", "Gate", "West"}],
}

visual = %Apple.Visual{
  background_color: "#1A1A1A",
  foreground_color: "#FFFFFF",
  label_color: "#D4A843",
  logo_text: "Summer Music Festival",
  icon_path: "priv/static/passes/icon.png",
  strip_image_path: "priv/static/passes/strip.png",
}

# Returns a signed .pkpass ZIP as a binary.
{:ok, pkpass_binary} = WalletPasses.build_apple_pass(pass_data, visual)

# Serve it from a Phoenix controller with the correct content type.
conn
|> put_resp_content_type("application/vnd.apple.pkpass")
|> put_resp_header("content-disposition", ~s(attachment; filename="ticket.pkpass"))
|> send_resp(200, pkpass_binary)

That binary is everything Apple Wallet needs: a manifest, every asset, a signed pass.json, and the certificate chain — all packed into one ZIP.

Concepts

This section explains the moving parts of Apple Wallet for readers new to PassKit. Skip it if you already know what a .pkpass is.

The .pkpass is a signed ZIP

A .pkpass is just a ZIP archive with a specific layout. The OS recognizes it by MIME type (application/vnd.apple.pkpass), unpacks it, validates the signature, and renders the pass according to pass.json. The bundle's required entries are:

ticket.pkpass               (ZIP archive)
 pass.json               (pass content + metadata)
 manifest.json           (SHA1 hash of every other file)
 signature               (PKCS#7 detached signature of manifest.json)
 icon.png                (required  small icon shown in lock-screen / Apple Watch)
 icon@2x.png             (optional  retina variant)
 icon@3x.png             (optional  super-retina variant)
 logo.png                (optional)
 strip.png               (optional)
 thumbnail.png           (optional)
 background.png          (optional, event tickets only)
 footer.png              (optional, boarding passes only)
 <locale>.lproj/         (optional  per-locale strings + images)
     pass.strings
     strip.png
     ...

The OS is strict: missing icon.png is a hard error, a bad signature is a hard error, a hash mismatch in manifest.json is a hard error. There is no graceful degradation.

pass.json shape

pass.json is a single JSON document carrying both metadata (pass type identifier, team ID, serial number, auth token, signing-relevant URLs) and content (one of five "style" keys: eventTicket, boardingPass, storeCard, coupon, generic — each containing arrays of headerFields, primaryFields, secondaryFields, auxiliaryFields, and backFields).

Apple's reference is PassKit Package Format Reference. This library never asks you to write pass.json by hand — Apple.Builder.build_pass_json/3 constructs it from PassData + Apple.Visual.

Signing: PKCS#7 / CMS

The signature file is a detached PKCS#7 (also called CMS) signature over manifest.json. "Detached" means it doesn't contain the manifest data itself — only the cryptographic signature plus the certificate chain used to verify it. Apple's signing requirements:

  • SHA-1 as the message digest (not SHA-256 — Apple is unusually conservative here).
  • RSA as the signature algorithm.
  • Signed attributes including content type, signing time, and the SHA-1 digest of the content.
  • Three certificates in the bundled chain: the pass type ID cert (the signer), the Apple WWDR intermediate, and the Apple Root CA — which is trusted by iOS and doesn't need to ship inside the signature.

The OS verifies the signature against the chain and rejects passes whose chain doesn't lead to Apple's root.

Web Service Protocol

Once a .pkpass is on a device, the device follows the URL in the pass's webServiceURL field to keep itself in sync. Apple's Web Service Protocol defines four REST endpoints the issuer must implement:

  • POST /v1/devices/:device_id/registrations/:pass_type_id/:serial_number — the device announces "I have this pass and here's my APNs push token."
  • DELETE /v1/devices/:device_id/registrations/:pass_type_id/:serial_number — the device announces "stop pushing me about this pass."
  • GET /v1/passes/:pass_type_id/:serial_number — the device pulls the latest pass binary. The issuer signs and returns a fresh .pkpass.
  • GET /v1/devices/:device_id/registrations/:pass_type_id — the device asks "which serials am I registered for?" (used for catch-up syncs).

Every request carries Authorization: ApplePass <auth_token>, where <auth_token> is the per-serial token the issuer baked into pass.json at build time. The issuer rejects mismatches with 401. This guide's "Web Service Protocol routes" section walks through what Apple.Router does for each.

APNs silent push

When pass content changes (a ticket gets voided, a flight's gate updates, a loyalty balance changes), the issuer sends a silent background push to every device registered for that serial. The push payload is empty — literally {}. iOS receives it and, instead of showing a notification, quietly hits the issuer's GET /v1/passes/... endpoint to pull the updated pass.

The push uses HTTP/2 to api.push.apple.com with client certificate authentication — the same pass type ID cert used to sign the .pkpass also authenticates the push. The relevant headers:

  • apns-topic: the pass type identifier (e.g. pass.com.example.mypass).
  • apns-push-type: background.
  • apns-priority: 5 (low priority, no user-visible payload).

There is no "push and the device updates immediately" guarantee. iOS batches these pushes, defers them on low-power conditions, and may delay delivery for minutes or hours. Devices also poll on their own schedule, so the push is an accelerator — not the only path. See "APNs push" below for why devices sometimes don't re-fetch right away.

Using This Library

Building a .pkpass

The top-level entry point is WalletPasses.build_apple_pass/3:

{:ok, pkpass_binary} =
  WalletPasses.build_apple_pass(pass_data, apple_visual, opts)

What it does, in order:

  1. Calls Schema.get_or_create_apple_pass/1 to look up (or create) the per-serial auth_token. The token is generated once per serial and persists in the wallet_passes_apple table.
  2. Calls Apple.Builder.build_pkpass/4 with the resolved auth token.

If you need direct control over the auth token (rare — e.g. you're building outside the standard persistence path), call Apple.Builder.build_pkpass/4 directly:

{:ok, pkpass_binary} =
  WalletPasses.Apple.Builder.build_pkpass(pass_data, visual, "your-auth-token", opts)

Supported opts:

  • :translations%{locale_tag => %{source_string => translated_string}}. Writes <locale>.lproj/pass.strings files into the ZIP. See Localization for the full reference.
  • :localized_images%{locale_tag => %{filename => path_on_disk}}. Writes per-locale image variants into the same .lproj/ directories. Missing files on disk are silently skipped.

The function returns {:ok, binary} on success or {:error, reason} on failure. Common reasons:

  • :no_signing_credentials — one of apple_pass_type_cert, apple_pass_type_key, or apple_wwdr_cert is missing or unreadable.
  • {:signing_failed, message} — PKCS#7 signing raised. See the Troubleshooting section.
  • {:zip_error, reason} — the underlying :zip.create/3 call failed.

pass.json field mapping

Apple.Builder.build_pass_json/3 transforms PassData + Apple.Visual + auth token into a pass.json map. The mapping is:

pass.json fieldSource
formatVersionhard-coded 1
passTypeIdentifierconfig :wallet_passes, :apple_pass_type_id
teamIdentifierconfig :wallet_passes, :apple_team_id
serialNumberpass_data.serial_number
authenticationTokenthe auth token argument
webServiceURLconfig :wallet_passes, :apple_web_service_url (omitted if nil)
organizationNamepass_data.organization_name (defaults to "")
descriptionpass_data.description (defaults to "")
backgroundColor/foregroundColor/labelColorvisual.background_color etc., converted #RRGGBB -> rgb(r, g, b)
logoTextvisual.logo_text (omitted if nil)
barcodes (array) + barcode (legacy single)pass_data.barcode_message (or serial_number as fallback), format PKBarcodeFormatQR, encoding iso-8859-1, optional altText from pass_data.barcode_alt_text
locations[{latitude, longitude}] if both set on pass_data
relevantDatepass_data.start_date rendered W3C-compliant with pass_data.timezone offset (falls back to Z for missing/invalid TZ)
<style> map (eventTicket, boardingPass, etc.)resolved from pass_data.pass_type via PassType.apple_style_key/1
<style>.headerFields/primaryFields/secondaryFields/auxiliaryFields/backFieldseach {key, label, value} tuple in pass_data mapped to %{"key" => k, "label" => l, "value" => v} (empty sections omitted)
<style>.transitTypefor :boarding_pass only; from pass_data.transit_type (defaults to :air -> "PKTransitTypeAir")
nfcwhen both nfc_message and nfc_encryption_public_key are set on pass_data; see NFC & Smart Tap

Notes:

  • Colors are converted, not passed through. You write "#1A1A1A"; the library emits "rgb(26, 26, 26)". This is what Apple requires.
  • relevantDate needs a timezone. Apple rejects naive YYYY-MM-DDT00:00:00 strings. The library combines start_date with timezone (an IANA name like "America/New_York") into an ISO 8601 string with offset. If timezone is nil or unknown to your tzdata, it falls back to T00:00:00Z, which is valid but treats your date as UTC.
  • Empty field sections are omitted. A secondary_fields: [] produces no secondaryFields key in the JSON, not secondaryFields: [].
  • Both barcodes and barcode are emitted. The plural form is the modern (iOS 9+) field; the singular form is the legacy iOS 7/8 field. The library writes both for back-compat. They carry identical content.

Apple.Visual

%WalletPasses.Apple.Visual{} carries the platform-specific styling that doesn't fit in the platform-agnostic PassData:

%WalletPasses.Apple.Visual{
  background_color: "#1A1A1A",
  foreground_color: "#FFFFFF",
  label_color: "#D4A843",
  logo_text: "My Event",
  icon_path: "priv/static/passes/icon.png",
  strip_image_path: "priv/static/passes/strip.png",
  thumbnail_path: "priv/static/passes/thumbnail.png",
}

Every field is optional except icon_path — Apple rejects passes without an icon.png. The library reads each path lazily during build_pkpass/4 and silently skips any file it can't open, which means a typo'd path will not raise — it will produce a pass that Apple rejects later. Verify your paths exist when wiring up PassDataProvider.

For pre-built visuals from a shared Theme, see Theming & Visual Design.

Image variants and @2x/@3x

Apple Wallet recognizes three density tiers per image asset:

Filename in ZIPDensityDevices
icon.png1xnon-retina (rare today)
icon@2x.png2xmost iPhones and iPads
icon@3x.png3xPlus / Pro Max / large iPads

This library has dedicated fields for the base names only — icon_path, strip_image_path, thumbnail_path map to icon.png, strip.png, thumbnail.png in the ZIP. To ship retina variants, name your files on disk with the @2x / @3x suffix and point a separate field at them via the :localized_images opt with the special "base" locale — or more straightforwardly, copy them under a .lproj/-less subdirectory and adjust your PassDataProvider to wire each asset path explicitly.

In practice, most issuers ship @2x only (modern iPhones) and skip both 1x and 3x. The library doesn't enforce which densities you provide — it packs whatever you hand it.

See Theming & Visual Design for the dimensions of each asset per pass type.

PKCS#7 signing (pure-Erlang)

WalletPasses.Apple.PKCS7.sign/4 constructs the detached PKCS#7 SignedData structure that Apple requires. It uses only OTP's :public_key and :crypto — no calls to an external openssl binary, no spawning a port, no temp files. The rationale:

  • No openssl dependency. The library works in slim Docker images and on any host with Erlang/OTP — release images, Alpine, custom distroless containers. The passbook library shells out to openssl smime, which is why wallet_passes doesn't depend on it.
  • No process spawning. Signing happens in-process, which makes it cheaper and dramatically easier to reason about under load.
  • Deterministic decoding. All record extraction uses OTP's bundled OTP-PUB-KEY.hrl ASN.1 definitions (CMS / RFC 5652), so there's no string-parsing or output-scraping.

The signer:

  1. Decodes the pass type ID certificate's PEM and extracts its IssuerAndSerialNumber — Apple's verifier matches signers by issuer + serial, not by SubjectKeyIdentifier.
  2. Decodes the WWDR intermediate's PEM (passed via apple_wwdr_cert).
  3. Decodes the private key's PEM (RSA, PKCS#1 or PKCS#8).
  4. Computes SHA-1(manifest.json) and assembles a SignedAttributes set with three OID attributes: content type (id-data), signing time (UTC), and message digest.
  5. DER-encodes the SignedAttributes, signs with RSA-SHA1.
  6. Assembles the SignerInfo, attaches certs (signer + WWDR — the Apple Root is trusted on-device and isn't bundled), and emits the final ContentInfo DER.

PKCS7.sign/4 returns {:ok, der_binary}. Errors come back as {:error, {:signing_failed, message}} (or :invalid_certificate, :invalid_private_key, :no_extra_certificates when the input PEMs don't decode).

You almost never call PKCS7 directly. Apple.Builder calls it during build_pkpass/4 and never exposes the intermediate signature to callers.

The authenticationToken

Apple's Web Service Protocol depends on a per-serial bearer token. The library generates this when you call build_apple_pass/3 (or directly via Schema.get_or_create_apple_pass/1) and persists it in the wallet_passes_apple table. It's then:

  1. Baked into pass.json as authenticationToken. The OS sees it when it parses the bundle.
  2. Checked on every Web Service Protocol call via the Authorization: ApplePass <token> header. Apple.Router looks up the row by serial_number + auth_token and returns 401 on mismatch.

Tokens are random and opaque — there's no structure to them. Rotating a token means rebuilding and re-pushing the pass (rare; usually only after a suspected credential leak).

Web Service Protocol routes

Mount Apple.Router in your Phoenix endpoint or Plug.Router, outside any CSRF-protected pipeline:

# lib/my_app_web/router.ex
forward "/passes/apple", WalletPasses.Apple.Router

Then set :apple_web_service_url to the resulting URL prefix:

# config/config.exs
config :wallet_passes,
  apple_web_service_url: "https://yourdomain.com/passes/apple"

The library writes that URL into every pass's webServiceURL field. iOS appends the protocol's path (/v1/devices/.../...) and authenticates with the per-pass token.

Every route on Apple.Router:

POST /v1/devices/:device_id/registrations/:pass_type_id/:serial_number

Called when a device adds a pass to Wallet (or recovers from a re-install). Body: {"pushToken": "<apns-token>"}.

The handler:

  1. Extracts Authorization: ApplePass <token> and looks up the ApplePass row by serial + auth token.
  2. On a match, inserts a wallet_pass_device_registrations row keyed by (apple_pass_id, device_library_id).
  3. Fires the :pass_added event (see Event Handling & Wallet Presence).
  4. Returns 201 Created.

On a missing-or-mismatched auth token, returns 401. The body's pushToken becomes the APNs target for future silent pushes.

DELETE /v1/devices/:device_id/registrations/:pass_type_id/:serial_number

Called when a device removes a pass — or when iOS rotates a push token, or when the Wallet app is uninstalled. This is not a reliable "user deleted the pass" signal — see Event Handling & Wallet Presence for the disambiguation.

The handler deletes the registration row, fires :pass_removed, and returns 200. 401 for bad auth.

GET /v1/passes/:pass_type_id/:serial_number

Called when a device fetches an updated pass — usually triggered by a silent APNs push or by a periodic background sync.

The handler:

  1. Extracts and validates the auth token.
  2. Calls your PassDataProvider.build_pass_data/1 to assemble the latest pass content.
  3. Calls Apple.Builder.build_pkpass/4 to sign and zip it.
  4. Fires :pass_fetched.
  5. Returns the binary with Content-Type: application/vnd.apple.pkpass.

This is the only route that uses your PassDataProvider. If your provider raises or returns {:error, _}, the response is 500 (or 404 for :not_found).

GET /v1/devices/:device_id/registrations/:pass_type_id

Called during catch-up syncs (device just connected to network, or just woke up). The handler:

  1. Queries every serial currently registered to device_library_id.
  2. Returns {"serialNumbers": [...], "lastUpdated": "<ISO 8601>"}.

If the device has no registrations, returns 204 No Content. Apple does support a passesUpdatedSince=<lastUpdated> query parameter for incremental sync; this library currently returns the full list every time, which is correct for small device fleets but does a bit more work than the protocol's optimum. See "What's NOT Covered" below.

APNs push

WalletPasses.notify_apple_devices("ticket-001")

Pushes every device registered for "ticket-001". Returns {:ok, {success_count, error_count}} after every push has been attempted.

What happens under the hood:

  1. Schema.list_push_tokens_for_serial/1 enumerates device registrations for the serial.
  2. Apple.Push.notify_devices/1 loads the pass type ID cert + key (the same ones used for .pkpass signing) and configures the HTTP/2 client cert.
  3. For each token, it sends POST /3/device/<token> to api.push.apple.com:443 with an empty {} body and the standard wallet headers.
  4. Successful (2xx) deliveries are counted as successes; everything else is an error. Individual errors are not propagated — the function returns aggregate counts.

The :apple_push_base_url config defaults to "https://api.push.apple.com:443" (the production endpoint). For sandbox/staging or local Bypass-based testing, override it:

# config/test.exs
config :wallet_passes, apple_push_base_url: "http://localhost:#{port}"

Silent pushes use HTTP/2 with versions: [:"tlsv1.2"] (TLS 1.3 is not yet universally supported by :hackney's Erlang stack; Req works fine over TLS 1.2 against Apple).

Why devices sometimes don't re-fetch immediately

iOS treats wallet pushes as best-effort background notifications. The OS may:

  • Defer pushes on low power. A device in Low Power Mode batches background pushes until conditions improve.
  • Coalesce repeated pushes. Multiple pushes for the same serial sent in quick succession arrive as one.
  • Refuse pushes on metered networks. Cellular-only devices on data saver may defer the fetch until WiFi.
  • Skip pushes for unfocused passes. Passes not pinned to the lock-screen or recently opened are deprioritized.

In practice, you should expect re-fetch latency from "within seconds" on a charged, WiFi-connected, foregrounded device to "an hour or more" on a backgrounded device with limited connectivity. Devices also poll on their own — usually daily — even without a push.

If a user reports "my pass shows the old content," the fix is rarely "send another push." It's usually waiting, telling the user to open Wallet (which forces a sync), or — in extreme cases — having the user remove and re-add the pass.

Recipes

Recipe 1: Build, store, and serve a pass

# In your controller:
def show(conn, %{"ticket_id" => ticket_id}) do
  ticket = MyApp.Tickets.get!(ticket_id)

  pass_data = %WalletPasses.PassData{
    serial_number: ticket.serial,
    pass_type: :event_ticket,
    description: "#{ticket.event_name} ticket",
    organization_name: "Festival Co",
    event_name: ticket.event_name,
    primary_fields: [{"name", "Name", ticket.holder_name}],
    secondary_fields: [
      {"section", "Section", ticket.section},
      {"row", "Row", ticket.row},
    ],
    barcode_message: ticket.serial,
    start_date: ticket.event_date,
    timezone: "America/New_York",
  }

  visual = %WalletPasses.Apple.Visual{
    background_color: "#1A1A1A",
    foreground_color: "#FFFFFF",
    label_color: "#D4A843",
    logo_text: ticket.event_name,
    icon_path: "priv/static/passes/icon.png",
    strip_image_path: "priv/static/passes/strip.png",
  }

  {:ok, pkpass} = WalletPasses.build_apple_pass(pass_data, visual)

  conn
  |> put_resp_content_type("application/vnd.apple.pkpass")
  |> put_resp_header(
    "content-disposition",
    ~s(attachment; filename="#{ticket.serial}.pkpass")
  )
  |> send_resp(200, pkpass)
end

That's a complete "click this link, get a wallet pass" flow. Apple Wallet opens automatically on iOS; on macOS it prompts.

Recipe 2: Push an update after a content change

# After updating ticket data in your DB:
def void_ticket(ticket) do
  MyApp.Tickets.mark_voided!(ticket)

  # Tell every device "your pass is stale, come fetch the new one."
  # This triggers GET /v1/passes/... which calls your PassDataProvider —
  # so the next thing each device sees reflects the voided state.
  WalletPasses.notify_apple_devices(ticket.serial)
end

Three things to know:

  1. The push doesn't carry content. It's an empty payload. The device re-fetches via your PassDataProvider.
  2. Make sure PassDataProvider produces the updated pass. If your provider still returns active-looking data, the device will fetch the same content it already has.
  3. For pass lifecycle (void/expire/complete) transitions, prefer the WalletPasses.void_pass/1 / expire_pass/1 / complete_pass/1 helpers — they update the DB status, patch Google's state, and push Apple devices in one call. See Pass Lifecycle & Updates.

Recipe 3: Wire up the Web Service Protocol

# lib/my_app_web/router.ex — forward outside any CSRF pipeline.
forward "/passes/apple", WalletPasses.Apple.Router

# config/config.exs
config :wallet_passes,
  apple_pass_type_id: "pass.com.example.mypass",
  apple_web_service_url: "https://yourdomain.com/passes/apple",
  pass_data_provider: MyApp.WalletPassProvider

# lib/my_app/wallet_pass_provider.ex
defmodule MyApp.WalletPassProvider do
  @behaviour WalletPasses.PassDataProvider

  @impl true
  def build_pass_data(serial) do
    case MyApp.Tickets.find_by_serial(serial) do
      nil -> {:error, :not_found}
      ticket -> {:ok, %{
        pass_data: pass_data_for(ticket),
        apple: apple_visual_for(ticket),
        google: google_visual_for(ticket),
      }}
    end
  end
end

iOS then calls GET /passes/apple/v1/passes/..., the router authenticates, runs your provider, signs the pkpass, and returns it. See Getting Started for a complete PassDataProvider example.

Recipe 4: Inspect a built pass locally

apple_web_service_url: nil (or unset) tells the builder to omit the webServiceURL from pass.json. iOS won't register the device, but the bundle is otherwise complete and inspectable:

unzip -l ticket.pkpass
# pass.json, manifest.json, signature, icon.png, strip.png, ...
unzip -p ticket.pkpass pass.json | jq .

See Local Development for the bundled dev/wallet_passes_dev/ sandbox app and Bypass patterns for stubbing APNs.

What's NOT Covered

  • App-specific data (userInfo). Apple supports an userInfo field for arbitrary JSON passed to an associated iOS app. This library doesn't expose it; populate it via direct manipulation of the pass if you need it.
  • Custom associatedStoreIdentifiers. The library doesn't expose fields for tying a pass to an iOS app store ID.
  • Encrypted/protected passes. Apple supports a sharingProhibited field that disables AirDrop sharing; this library doesn't expose it.
  • passesUpdatedSince query parameter. The Web Service Protocol allows the device to specify a since-cursor on the serial-enumeration endpoint. The router currently returns the full list every time. This is correct for issuers serving small numbers of passes per device but is wasteful for issuers shipping hundreds of passes to single devices.
  • Apple Watch–specific assets. The library doesn't differentiate watch images from phone images. Apple's renderer will use whatever icon.png you provide for both surfaces.
  • expirationDate field. There's no pass_data.expiration_date field today. For issuer-driven expiry, use WalletPasses.expire_pass/1 — see Pass Lifecycle & Updates. For date-based OS-managed expiry (rare), patch pass.json post-build, re-hash the manifest, and re-sign.
  • Multiple passes per .pkpass. Apple historically allowed a pass.pkpasses ZIP-of-ZIPs bundle. This library builds one pass per call.

Troubleshooting

"Invalid pass" when opening on iOS

By far the most common symptom — and it's usually one of three root causes.

(1) Bad signature. Open the .pkpass in a unzip listing and verify:

  • signature is present and non-empty.
  • manifest.json parses as JSON and lists every other file in the ZIP (except itself and signature).
  • The cert chain is intact: your pass type ID cert + WWDR intermediate.

In iOS Console (Mac with the phone connected, Console.app filtered to the device), look for PassKit errors. Common ones:

  • Untrusted signing certificate — the WWDR cert is missing or expired. Re-download the current WWDR G4 from Apple and point :apple_wwdr_cert at it.
  • Manifest SHA1 mismatch — a file was modified after manifest.json was generated, or some piece of middleware (a CDN, a web framework) is rewriting the body. Make sure your delivery path serves the binary byte-for-byte. Don't decode-and-re-encode the ZIP.
  • passTypeIdentifier mismatch — the :apple_pass_type_id config doesn't match the OID in the signing certificate. Double-check that the cert you uploaded is for the pass type ID you're using.

(2) Missing icon.png. Apple rejects the pass outright if icon.png is absent from the bundle. The library silently skips images it can't read from disk, so a typo in visual.icon_path will produce an invalid pass with no error at build time. Add a quick existence check in your provider:

unless File.exists?(visual.icon_path), do: raise "icon.png missing"

(3) Certificate expired. Apple's pass type certificates expire one year from issuance. The symptom is "Invalid pass" everywhere — even on previously working pass types. Renew at developer.apple.com and deploy the new cert + key.

Device receives the push but doesn't re-fetch

Order of likely causes:

  1. Auth-token mismatch. Your pass.json was built with one auth token, but the row in wallet_passes_apple was updated to a different one. Check that your build path uses WalletPasses.build_apple_pass/3 (which reads from the DB) rather than manually generating a fresh token each time.
  2. webServiceURL typo or stale. If apple_web_service_url changed after the pass shipped, the device is still hitting the old URL. Re-issue the pass with the corrected URL (the OS only reads webServiceURL from the bundle, not from a push).
  3. PassDataProvider returns the same content. The device fetches but sees no difference, so iOS treats the pass as unchanged. Make sure your provider reflects the new data.
  4. iOS is deferring the fetch. See "Why devices sometimes don't re-fetch immediately" — there's no fix for this except waiting or asking the user to open Wallet.

:no_signing_credentials from build_apple_pass/3

One of the three Apple credentials is missing or the value isn't a valid PEM. The library accepts file paths, PEM strings (-----BEGIN ...), and base64-encoded PEM, and tries them in that order. If none decode, it returns :no_signing_credentials.

Check:

  • Is :apple_pass_type_cert set in config/runtime.exs? (Use runtime config for secrets — config/config.exs is compiled into the release.)
  • Does the file exist at the path? Are file permissions correct in production?
  • If using base64, is the value the whole PEM base64-encoded, or just the certificate body? The library expects the former.

{:signing_failed, _}

The PKCS#7 signer raised. Common causes:

  • Mismatched cert + key. The pass type cert's public key doesn't match the private key. Re-export both from the Apple Developer portal in one shot.
  • Encrypted private key. OTP's :public_key decodes unencrypted PEM. Strip the password with openssl rsa -in encrypted.pem -out unencrypted.pem once, store the result.
  • WWDR cert in the wrong slot. If you swapped :apple_pass_type_cert and :apple_wwdr_cert, signing succeeds but iOS rejects the pass. Verify which cert is which (the WWDR cert has Subject CN=Apple Worldwide Developer Relations).

"The pass renders but the icon is blank"

iOS renders passes with missing strip/thumbnail images, but a missing or unreadable icon.png produces a placeholder. The library silently skips images it can't read from disk, so a typo'd visual.icon_path passes through build with no error. Confirm icon_path resolves to a real file and consider shipping icon@2x.png for retina crispness.

Certificate expiry symptoms

Apple's pass type certs are valid for one year. After expiry, existing passes keep displaying (Apple doesn't re-validate on render), but new builds fail signing and APNs pushes return 403 BadCertificate. The fix is to renew the cert at developer.apple.com, update :apple_pass_type_cert/:apple_pass_type_key, and re-issue any passes that need updates from this point forward.

Bytes are being modified in transit

If you're serving .pkpass through a CDN or reverse proxy and seeing "Invalid pass" intermittently, the usual culprit is gzip compression or content-type rewriting. The binary must reach the device byte-for-byte: disable transformation for application/vnd.apple.pkpass responses, set the content type exactly, and never decode-and-re-encode the body.

API Reference

The Apple-specific functions in this library:

WalletPasses.build_apple_pass/3

@spec build_apple_pass(PassData.t(), Apple.Visual.t(), keyword()) ::
        {:ok, binary()} | {:error, term()}

Top-level helper. Looks up or creates the ApplePass row (and its auth token), then builds the .pkpass. Accepts :translations and :localized_images in opts. Emits [:wallet_passes, :apple, :build_pass, :start|:stop|:exception] telemetry — see Telemetry.

WalletPasses.notify_apple_devices/1

@spec notify_apple_devices(String.t()) ::
        {:ok, {success :: non_neg_integer(), error :: non_neg_integer()}}
        | {:error, term()}

Pushes every device registered for the given serial number. Returns counts; individual errors are not propagated. Emits [:wallet_passes, :apple, :push, :start|:stop].

WalletPasses.Apple.Builder

  • build_pass_json/3(pass_data, visual, auth_token) -> map. The pure-data shape of pass.json. Useful for inspection and tests.
  • build_pkpass/4(pass_data, visual, auth_token, opts) -> {:ok, binary} | {:error, term}. The full build pipeline.
  • generate_manifest/1(%{filename => binary}) -> %{filename => sha1_hex}. Exposed for tests.

WalletPasses.Apple.PKCS7.sign/4

@spec sign(binary(), binary(), binary(), binary()) ::
        {:ok, binary()} | {:error, term()}

Low-level signer. data, cert_pem, key_pem, extra_certs_pem. Returns DER-encoded PKCS#7 SignedData.

WalletPasses.Apple.Push.notify_devices/1

@spec notify_devices([String.t()]) ::
        {:ok, {non_neg_integer(), non_neg_integer()}} | {:error, term()}

Sends silent background pushes to a list of APNs tokens. Same return shape as notify_apple_devices/1.

WalletPasses.Apple.Router

A Plug.Router implementing the four Web Service Protocol endpoints. No init opts. Forward-mount it in your Phoenix router or Plug pipeline at the path matching :apple_web_service_url. See "Web Service Protocol routes" above for endpoint behaviour.

WalletPasses.Apple.Visual

%WalletPasses.Apple.Visual{
  background_color: String.t() | nil,
  foreground_color: String.t() | nil,
  label_color: String.t() | nil,
  logo_text: String.t() | nil,
  strip_image_path: String.t() | nil,
  thumbnail_path: String.t() | nil,
  icon_path: String.t() | nil
}

Struct of Apple-specific visual fields. Colors are #RRGGBB strings; image fields are filesystem paths read at build time.

Configuration keys

All under config :wallet_passes, …:

KeyRequiredDescription
:apple_pass_type_idyesPass type identifier (pass.com.example.foo)
:apple_team_idyesApple Developer team ID
:apple_pass_type_certyesPass type ID cert — file path, PEM string, or base64
:apple_pass_type_keyyesSigning key (matching the cert) — file path, PEM, or base64
:apple_wwdr_certyesApple WWDR G4 intermediate — file path, PEM, or base64
:apple_web_service_urlnoPublic URL where Apple.Router is mounted; omitted if nil
:apple_push_base_urlnoDefaults to "https://api.push.apple.com:443"; override for testing

See Getting Started for how to obtain each credential from the Apple Developer portal.