# Apple Wallet
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](getting-started.md). For shipping passes in multiple
languages, see [Localization](localization.md). For shipping Google Wallet
alongside Apple, see [Google Wallet](google-wallet.md).
## 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](getting-started.md) 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](theming.md) 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:
```elixir
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`.
[PassKit Package Format Reference]: https://developer.apple.com/library/archive/documentation/UserExperience/Reference/PassKit_Bundle/Chapters/Introduction.html
### 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`:
```elixir
{: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:
```elixir
{: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](localization.md) 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` 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](nfc.md) |
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`:
```elixir
%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](theming.md).
### 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](theming.md) 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:
```elixir
# lib/my_app_web/router.ex
forward "/passes/apple", WalletPasses.Apple.Router
```
Then set `:apple_web_service_url` to the resulting URL prefix:
```elixir
# 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](event-handling.md)).
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](event-handling.md) 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
```elixir
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:
```elixir
# 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
```elixir
# 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
```elixir
# 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](lifecycle.md).
### Recipe 3: Wire up the Web Service Protocol
```elixir
# 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](getting-started.md) 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:
```bash
unzip -l ticket.pkpass
# pass.json, manifest.json, signature, icon.png, strip.png, ...
unzip -p ticket.pkpass pass.json | jq .
```
See [Local Development](local-development.md) 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](lifecycle.md). 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:
```elixir
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`
```elixir
@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](telemetry.md).
### `WalletPasses.notify_apple_devices/1`
```elixir
@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`
```elixir
@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`
```elixir
@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`
```elixir
%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](getting-started.md) for how to obtain each
credential from the Apple Developer portal.