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
.pkpassZIP from aPassDatastruct + anApple.Visualstruct, returning the binary in-memory (no temp files). - Generates an Apple-required
authenticationTokenper 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
opensslbinary onPATH, 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_registrationstable 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.jsonand 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/@3xvariants 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:
- Calls
Schema.get_or_create_apple_pass/1to look up (or create) the per-serialauth_token. The token is generated once per serial and persists in thewallet_passes_appletable. - Calls
Apple.Builder.build_pkpass/4with 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.stringsfiles 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 ofapple_pass_type_cert,apple_pass_type_key, orapple_wwdr_certis missing or unreadable.{:signing_failed, message}— PKCS#7 signing raised. See the Troubleshooting section.{:zip_error, reason}— the underlying:zip.create/3call 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 field | Source |
|---|---|
formatVersion | hard-coded 1 |
passTypeIdentifier | config :wallet_passes, :apple_pass_type_id |
teamIdentifier | config :wallet_passes, :apple_team_id |
serialNumber | pass_data.serial_number |
authenticationToken | the auth token argument |
webServiceURL | config :wallet_passes, :apple_web_service_url (omitted if nil) |
organizationName | pass_data.organization_name (defaults to "") |
description | pass_data.description (defaults to "") |
backgroundColor/foregroundColor/labelColor | visual.background_color etc., converted #RRGGBB -> rgb(r, g, b) |
logoText | visual.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 |
relevantDate | pass_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/backFields | each {key, label, value} tuple in pass_data mapped to %{"key" => k, "label" => l, "value" => v} (empty sections omitted) |
<style>.transitType | for :boarding_pass only; from pass_data.transit_type (defaults to :air -> "PKTransitTypeAir") |
nfc | when 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. relevantDateneeds a timezone. Apple rejects naiveYYYY-MM-DDT00:00:00strings. The library combinesstart_datewithtimezone(an IANA name like"America/New_York") into an ISO 8601 string with offset. Iftimezoneisnilor unknown to your tzdata, it falls back toT00:00:00Z, which is valid but treats your date as UTC.- Empty field sections are omitted. A
secondary_fields: []produces nosecondaryFieldskey in the JSON, notsecondaryFields: []. - Both
barcodesandbarcodeare 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 ZIP | Density | Devices |
|---|---|---|
icon.png | 1x | non-retina (rare today) |
icon@2x.png | 2x | most iPhones and iPads |
icon@3x.png | 3x | Plus / 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
openssldependency. The library works in slim Docker images and on any host with Erlang/OTP — release images, Alpine, custom distroless containers. Thepassbooklibrary shells out toopenssl smime, which is whywallet_passesdoesn'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.hrlASN.1 definitions (CMS / RFC 5652), so there's no string-parsing or output-scraping.
The signer:
- Decodes the pass type ID certificate's PEM and extracts its
IssuerAndSerialNumber— Apple's verifier matches signers by issuer + serial, not by SubjectKeyIdentifier. - Decodes the WWDR intermediate's PEM (passed via
apple_wwdr_cert). - Decodes the private key's PEM (RSA, PKCS#1 or PKCS#8).
- Computes
SHA-1(manifest.json)and assembles aSignedAttributesset with three OID attributes: content type (id-data), signing time (UTC), and message digest. - DER-encodes the
SignedAttributes, signs with RSA-SHA1. - Assembles the
SignerInfo, attaches certs (signer + WWDR — the Apple Root is trusted on-device and isn't bundled), and emits the finalContentInfoDER.
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:
- Baked into
pass.jsonasauthenticationToken. The OS sees it when it parses the bundle. - Checked on every Web Service Protocol call via the
Authorization: ApplePass <token>header.Apple.Routerlooks up the row byserial_number + auth_tokenand returns401on 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.RouterThen 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:
- Extracts
Authorization: ApplePass <token>and looks up theApplePassrow by serial + auth token. - On a match, inserts a
wallet_pass_device_registrationsrow keyed by(apple_pass_id, device_library_id). - Fires the
:pass_addedevent (see Event Handling & Wallet Presence). - 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:
- Extracts and validates the auth token.
- Calls your
PassDataProvider.build_pass_data/1to assemble the latest pass content. - Calls
Apple.Builder.build_pkpass/4to sign and zip it. - Fires
:pass_fetched. - 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:
- Queries every serial currently registered to
device_library_id. - 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:
Schema.list_push_tokens_for_serial/1enumerates device registrations for the serial.Apple.Push.notify_devices/1loads the pass type ID cert + key (the same ones used for.pkpasssigning) and configures the HTTP/2 client cert.- For each token, it sends
POST /3/device/<token>toapi.push.apple.com:443with an empty{}body and the standard wallet headers. - 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)
endThat'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)
endThree things to know:
- The push doesn't carry content. It's an empty payload. The device
re-fetches via your
PassDataProvider. - Make sure
PassDataProviderproduces the updated pass. If your provider still returns active-looking data, the device will fetch the same content it already has. - For pass lifecycle (void/expire/complete) transitions, prefer the
WalletPasses.void_pass/1/expire_pass/1/complete_pass/1helpers — 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
endiOS 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 anuserInfofield 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
sharingProhibitedfield that disables AirDrop sharing; this library doesn't expose it. passesUpdatedSincequery 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.pngyou provide for both surfaces. expirationDatefield. There's nopass_data.expiration_datefield today. For issuer-driven expiry, useWalletPasses.expire_pass/1— see Pass Lifecycle & Updates. For date-based OS-managed expiry (rare), patchpass.jsonpost-build, re-hash the manifest, and re-sign.- Multiple passes per
.pkpass. Apple historically allowed apass.pkpassesZIP-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:
signatureis present and non-empty.manifest.jsonparses as JSON and lists every other file in the ZIP (except itself andsignature).- 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_certat it.Manifest SHA1 mismatch— a file was modified aftermanifest.jsonwas 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_idconfig 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:
- Auth-token mismatch. Your
pass.jsonwas built with one auth token, but the row inwallet_passes_applewas updated to a different one. Check that your build path usesWalletPasses.build_apple_pass/3(which reads from the DB) rather than manually generating a fresh token each time. webServiceURLtypo or stale. Ifapple_web_service_urlchanged after the pass shipped, the device is still hitting the old URL. Re-issue the pass with the corrected URL (the OS only readswebServiceURLfrom the bundle, not from a push).PassDataProviderreturns 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.- 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_certset inconfig/runtime.exs? (Use runtime config for secrets —config/config.exsis 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_keydecodes unencrypted PEM. Strip the password withopenssl rsa -in encrypted.pem -out unencrypted.pemonce, store the result. - WWDR cert in the wrong slot. If you swapped
:apple_pass_type_certand:apple_wwdr_cert, signing succeeds but iOS rejects the pass. Verify which cert is which (the WWDR cert hasSubject 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 ofpass.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, …:
| Key | Required | Description |
|---|---|---|
:apple_pass_type_id | yes | Pass type identifier (pass.com.example.foo) |
:apple_team_id | yes | Apple Developer team ID |
:apple_pass_type_cert | yes | Pass type ID cert — file path, PEM string, or base64 |
:apple_pass_type_key | yes | Signing key (matching the cert) — file path, PEM, or base64 |
:apple_wwdr_cert | yes | Apple WWDR G4 intermediate — file path, PEM, or base64 |
:apple_web_service_url | no | Public URL where Apple.Router is mounted; omitted if nil |
:apple_push_base_url | no | Defaults to "https://api.push.apple.com:443"; override for testing |
See Getting Started for how to obtain each credential from the Apple Developer portal.