This guide explains how to colour and decorate a wallet pass with
wallet_passes. Apple and Google diverge sharply: Apple bakes images
into the signed .pkpass bundle, while Google references images by
URL. The library offers a single Theme struct for the overlap
(colours plus logo text) and per-platform Visual structs for the
rest.
Overview
A pass has two layers of styling:
- Shared style — colours and a small piece of header text that
appear on both platforms.
WalletPasses.Themecarries this;Theme.to_apple_visual/1/Theme.to_google_visual/1convert it into the per-platform structs. - Platform-specific assets — image files for Apple (
icon.png,logo.png,strip.png,thumbnail.png, plus retina variants) and hosted image URIs for Google (logo_uri,hero_image_uri,image_modules). These live onWalletPasses.Apple.VisualandWalletPasses.Google.Visualand never overlap.
Theme is purely a convenience — skip it and build the two Visual
structs by hand if that suits you better. The rest of the library
never reads Theme directly.
Concepts
Apple: signed image set + hex colours
An Apple .pkpass is a ZIP file containing image files alongside
pass.json. iOS reads those files locally — no network required after
the pass is on-device. Colours are written into pass.json as
CSS-style rgb(r, g, b) strings; the library accepts standard
#RRGGBB hex on the Visual struct and converts on the way out.
{
"backgroundColor": "rgb(26, 26, 26)",
"foregroundColor": "rgb(255, 255, 255)",
"labelColor": "rgb(212, 168, 67)"
}The image set is fixed: Apple recognises specific filenames
(icon.png, logo.png, strip.png, thumbnail.png, background.png,
footer.png) and ignores anything else. Retina variants use the
@2x / @3x suffix. Every file in the ZIP is hashed into
manifest.json, which is PKCS#7-signed.
Google: hosted URIs + hex string for background
Google Wallet stores pass content server-side. Images aren't packaged — Google fetches them from URLs you supply and re-hosts them on Google's CDN. You're responsible for keeping those URLs reachable while the pass is in use.
{
"hexBackgroundColor": "#1A1A1A",
"logo": {
"sourceUri": {"uri": "https://example.com/logo.png"},
"contentDescription": {
"defaultValue": {"language": "en-US", "value": "Logo"}
}
}
}Google's hexBackgroundColor accepts the hex string directly — no
conversion needed. There's only one background colour on Google (no
foreground / label distinction), so most theme entries don't have a
Google counterpart.
The Theme struct and conversion helpers
WalletPasses.Theme is a tiny shared-colour carrier:
defstruct [:name, :background_color, :foreground_color, :label_color, :logo_text]:name is informational only (never written to either platform — use
it to label the theme in your own code). The three colour fields are
#RRGGBB hex strings. :logo_text is the small string drawn next to
the logo on Apple passes; Google has no equivalent and ignores it.
Conversion helpers
theme = %WalletPasses.Theme{
background_color: "#1A1A1A",
foreground_color: "#FFFFFF",
label_color: "#D4A843",
logo_text: "My Event"
}
apple_visual =
theme
|> WalletPasses.Theme.to_apple_visual()
|> struct!(
icon_path: "priv/static/passes/icon.png",
strip_image_path: "priv/static/passes/strip.png"
)
google_visual =
theme
|> WalletPasses.Theme.to_google_visual()
|> struct!(
logo_uri: "https://cdn.example.com/passes/logo.png",
hero_image_uri: "https://cdn.example.com/passes/hero.png"
)to_apple_visual/1 copies the three colour fields plus :logo_text
into a fresh Apple.Visual; image paths come up nil. to_google_visual/1
copies only :background_color. Merge platform-specific assets in
with struct!/2.
Hex → Apple rgb() conversion
The library converts #RRGGBB to Apple's rgb(r, g, b) form
automatically at build time. The helper is public if you need it
elsewhere:
iex> WalletPasses.Theme.hex_to_apple_rgb("#1A1A1A")
"rgb(26, 26, 26)"
iex> WalletPasses.Theme.hex_to_apple_rgb("#D4A843")
"rgb(212, 168, 67)"Only #RRGGBB is supported. Short-form hex (#FFF), #RRGGBBAA,
and named colours raise — keep stored values in #RRGGBB form.
There is no inverse helper: Google takes the hex string straight
from Visual.background_color and writes it verbatim.
Apple image set
WalletPasses.Apple.Visual carries paths on disk for three image
slots:
| Field | Filename in .pkpass | Required by Apple? |
|---|---|---|
:icon_path | icon.png | Yes |
:strip_image_path | strip.png | Pass-type specific |
:thumbnail_path | thumbnail.png | Optional |
The builder reads each path with File.read/1. Missing files are
silently skipped — the pass still builds, but Apple will reject it
on the device if icon.png is absent. Always provide at least
icon_path.
Pass-type image conventions
Apple uses different "primary" images per pass style:
- Event tickets — typically
strip.png(full-width banner). - Boarding passes —
logo.pngplusfooter.png; no strip. - Store cards / loyalty —
strip.pngorbackground.png. - Coupons —
strip.png. - Generic —
thumbnail.png(right-aligned square) pluslogo.png.
Apple.Visual exposes the three most common slots directly. For
logo.png, background.png, or footer.png, ship them through the
:localized_images option on Apple.Builder.build_pkpass/4 (it works
for the default locale too — just key by your default-locale tag).
Retina variants and dimensions
iOS picks the highest-resolution match for the device. Provide retina
variants by shipping icon@2x.png and icon@3x.png alongside
icon.png — same for strip, thumbnail, etc. Apple.Visual doesn't
expose dedicated fields for retina variants; ship them via
:localized_images (filename keys like "icon@2x.png") or extend the
builder.
The library does not validate dimensions or resize — whatever you
provide ships verbatim, and iOS scales to fit. Exact pixel dimensions
per pass type and image slot vary across iOS versions; see Apple's
PassKit Package Format Reference for the canonical
numbers. Per-locale image variants live in <locale>.lproj/ — see
Localization.
Google image fields
WalletPasses.Google.Visual carries URIs (not paths) for the images
Google's servers fetch:
| Field | Google JSON path | Notes |
|---|---|---|
:logo_uri | logo.sourceUri.uri | Square logo, top-left of pass face |
:hero_image_uri | heroImage.sourceUri.uri | Wide banner across the top |
:wide_logo_uri | (struct-only, reserved) | Not currently emitted into JSON |
:image_modules | imageModulesData[].mainImage.* | List of {uri, description} tuples |
:wide_logo_uri is declared on the struct for forward-compatibility
but is not written into the Google JSON by the current API
builder. Set :logo_uri for the standard logo slot.
Content descriptions
Every image Google receives needs a contentDescription for screen
readers. The library generates these automatically — "Logo" for the
logo, "Hero image" for the hero, and the per-module description
string you supply in :image_modules. Content descriptions can be
localised; see the Localization guide.
Image hosting and modules
Google fetches every image once per object create/update and re-hosts it on Google's CDN. Practical constraints: HTTPS-only, reachable from Google's IP ranges, PNG/JPEG/WebP (no SVG). Pixel dimensions live in Google's image style guidelines; logos render in a small square, hero images render full-width across the pass top.
For longer passes that need additional inline imagery (event poster,
venue map, sponsor logo), use :image_modules:
google_visual = %WalletPasses.Google.Visual{
logo_uri: "https://cdn.example.com/logo.png",
hero_image_uri: "https://cdn.example.com/hero.png",
image_modules: [
{"https://cdn.example.com/poster.png", "Concert poster"},
{"https://cdn.example.com/map.png", "Venue map"}
]
}Each tuple is {uri, content_description}; order is preserved into
imageModulesData[]. Image modules are Google-only.
When to share colours vs use platform-specific visuals directly
Use Theme when the two platforms should share a palette — same
background at minimum, ideally the same brand identity. A single
%Theme{name: "Summer 2026"} constant declared once and consumed in
both PassDataProvider builds is the canonical case.
Build Apple.Visual and Google.Visual directly when the
platforms need genuinely different palettes (Apple's pass face has
more visual chrome — foreground / label colours matter — while
Google's flatter layout often looks better with different contrast),
when lifecycle decoration (see Pass Lifecycle) needs
per-platform hexes, or when you're testing one platform in isolation.
The two approaches are interchangeable; nothing else in the library
requires Theme.
API Reference
WalletPasses.Theme
%Theme{name, background_color, foreground_color, label_color, logo_text}.
to_apple_visual/1 copies all four colour/text fields into an
Apple.Visual (image paths nil). to_google_visual/1 copies only
:background_color (URIs nil). hex_to_apple_rgb/1 converts
"#RRGGBB" to "rgb(r, g, b)" and raises on malformed input.
WalletPasses.Apple.Visual
:background_color, :foreground_color, :label_color (all
#RRGGBB, converted to rgb() at build time), :logo_text, and the
image paths :icon_path, :strip_image_path, :thumbnail_path.
Missing files are silently skipped. new/1 is a struct! wrapper.
WalletPasses.Google.Visual
:background_color (#RRGGBB, written to hexBackgroundColor
verbatim), :logo_uri, :hero_image_uri (HTTPS), :wide_logo_uri
(reserved, not emitted), :image_modules (list of
{uri, content_description} tuples, defaults to []). new/1 is a
struct! wrapper.
Related guides
- Getting Started for the full build pipeline.
- Apple Wallet for the
.pkpassbundle layout and how image files are packaged and signed. - Google Wallet for hero/logo image hosting conventions and class vs object visuals.
- Localization for per-locale Apple images and Google content-description translation.
- Pass Types for which image slot is the "primary" visual on each pass type.