This guide explains how to ship wallet passes in multiple languages with
wallet_passes. Apple Wallet and Google Wallet localize passes through
completely different mechanisms, but this library exposes a single
:translations option that drives both — you write the translations once and
the library emits the right shape for each platform.
Overview
What's supported
- Text content on user-visible pass fields: field labels and values
(
header_fields,primary_fields,secondary_fields,auxiliary_fields,back_fields), the passdescription, theorganization_name, thelogo_text, the class-level event/program/title and venue strings, and text-module headers and bodies. - Image content descriptions (Google only — for accessibility).
- Per-locale strip images and icons (Apple only — Apple's
.lprojdirectories support locale-specific image variants).
What's not supported
- Proper nouns:
ticketHolderName,passengerName,accountName. Names generally aren't translated, so the library treats them as data, not display text. - Opaque payloads: barcode
message/value, NFCmessage, authentication tokens, web service URLs, serial numbers. These are identifiers, not display text. - Dates and coordinates: rendered by the OS using the device's locale conventions automatically — no translation step is needed (or possible).
- Automatic translation: you bring the translated strings. The library doesn't call any translation service.
Why the two platforms differ
Apple resolves locales on-device at display time: the .pkpass ships
every locale's pass.strings file inside <locale>.lproj/ directories, and
iOS looks up the user's preferred locale and substitutes strings using the
already-present pass.json value as the lookup key. Google resolves
server-side: the Wallet API stores LocalizedString objects with a
defaultValue and an array of translatedValues, and Google's servers
render the correct locale to each user's device. The unified :translations
map serves both — we transform it differently for each platform.
Quick Start
Define a translations map keyed by locale tag, with inner maps from the default-locale source string to its translation. Then pass it as an option to both Apple and Google builders.
alias WalletPasses.{Apple, Google, PassData}
# 1. Build your base PassData in the default locale (English here).
pass_data = %PassData{
serial_number: "ticket-42",
pass_type: :event_ticket,
description: "Summer Music Festival ticket",
organization_name: "Festival Co",
event_name: "Summer Music Festival",
secondary_fields: [
{"section", "Section", "A"},
{"row", "Row", "12"},
],
auxiliary_fields: [
{"gate", "Gate", "West"},
],
}
apple_visual = %Apple.Visual{logo_text: "Summer Music Festival"}
google_visual = %Google.Visual{logo_uri: "https://example.com/logo.png"}
# 2. Provide translations for each locale you support. Keys are the exact
# strings already present in pass_data — labels, values, and titles.
translations = %{
"fr" => %{
"Summer Music Festival" => "Festival de Musique d'Été",
"Section" => "Section",
"Row" => "Rangée",
"Gate" => "Porte",
"West" => "Ouest",
},
"es" => %{
"Summer Music Festival" => "Festival de Música de Verano",
"Section" => "Sección",
"Row" => "Fila",
"Gate" => "Puerta",
"West" => "Oeste",
},
}
# 3a. Apple: get a .pkpass binary with fr.lproj/ and es.lproj/ entries.
{:ok, pkpass} =
WalletPasses.build_apple_pass(pass_data, apple_visual, translations: translations)
# 3b. Google: get a Save URL whose underlying object and class carry
# translatedValues on every localizable field.
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
translations: translations,
class_config: %{
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival",
translations: translations
}
)That's it. Devices set to French or Spanish will see translated text automatically; devices set to anything else fall back to the default English strings already in the pass.
Locale Tag Convention
The map keys are passed to each platform verbatim as locale tags. We do not normalize, lowercase, or rewrite them.
When to use "fr" vs "fr-FR"
Default to bare language tags ("fr", "es", "de"). They match a wider
range of devices than region-specific tags and require less data duplication.
Use region-specific tags ("fr-CA", "es-419", "pt-BR") only when you have
genuinely different content for a region — Quebec French uses different
vocabulary than France French, Latin American Spanish differs from Iberian
Spanish, Brazilian Portuguese differs from European Portuguese.
How Apple's .lproj fallback works
Apple uses the standard NSLocale fallback rules. The library writes a
directory named <locale>.lproj for each top-level key in your translations
map. iOS then matches the user's preferred locale to a directory using a
chain like:
- User locale
fr-CA→ triesfr-CA.lproj→ falls back tofr.lproj→ falls back to the rawpass.jsonvalue (English). - User locale
fr-FR→ triesfr-FR.lproj→ falls back tofr.lproj→ falls back to the rawpass.jsonvalue. - User locale
en-US→ no.lprojmatches → uses the rawpass.jsonvalue.
So shipping a single fr.lproj covers all French-speaking regions
automatically. You only need separate fr-FR.lproj and fr-CA.lproj
directories if their translations actually differ.
How Google's locale matching works
Google performs verbatim matching on the language value of each entry
in translatedValues. There is no region fallback. A LocalizedString
entry with "language": "fr" matches devices reporting fr-FR, fr-CA,
and fr. An entry with "language": "fr-FR" matches only devices reporting
that exact tag.
In practice, bare language tags match more devices on Google. The
library passes whatever you write straight through — if you use "fr-FR"
keys, you'll get "fr-FR" translatedValues entries, which will miss
Quebec users. Use the broadest tag that fits your content.
How It Works: Apple
The pass.strings lookup mechanism
Apple Wallet doesn't replace pass.json content with a localized version.
Instead, it uses the string already in pass.json as the lookup key into
a pass.strings file. Concretely, if pass.json contains:
{
"secondaryFields": [
{"key": "gate", "label": "Gate", "value": "West"}
]
}and fr.lproj/pass.strings contains:
"Gate" = "Porte";
"West" = "Ouest";then a French-locale device displays "Porte: Ouest" while a non-French device
shows "Gate: West". The library never modifies pass.json for localization —
it only adds sibling <locale>.lproj/ entries in the ZIP.
Which pass.json fields participate
Apple's PassKit Package Format reference lists which strings get the implicit
.strings lookup. The fields that participate (and therefore can be
translated):
- Pass-structure field
label,value,attributedValue, andchangeMessage— acrossheaderFields,primaryFields,secondaryFields,auxiliaryFields, andbackFields. - Top-level
logoText. - Pass
description. organizationName.- Barcode
altText(the human-readable text below the QR code).
Fields that do NOT participate (treated as opaque data):
serialNumber,authenticationToken,webServiceURL,passTypeIdentifier,teamIdentifier.barcodes[].message— this is the encoded barcode payload, not display text.- Color hex strings, dates, coordinates, NFC payload.
Escaping and encoding
The library emits UTF-8 pass.strings files (no BOM). Modern iOS Wallet
accepts UTF-8 natively. You do not need to encode emoji or accented
characters specially — write them in your source map as ordinary UTF-8 and
they round-trip cleanly through the ZIP.
The library escapes the five characters that the .strings parser treats
specially:
\\(backslash)"(double-quote)\n(newline)\r(carriage return)\t(tab)
You don't need to escape these yourself in your translations map — pass raw strings.
Locale-specific images
Apple supports per-locale image variants alongside pass.strings. For
example, you can ship a French version of strip.png that has French text
baked into the image, and iOS will pick the locale-matched version. Use the
:localized_images option:
WalletPasses.build_apple_pass(pass_data, apple_visual,
translations: translations,
localized_images: %{
"fr" => %{
"strip.png" => "/path/to/fr/strip.png",
"icon.png" => "/path/to/fr/icon.png"
},
"es" => %{
"strip.png" => "/path/to/es/strip.png"
}
}
)The inner map's keys are the raw filenames Apple recognizes (icon.png,
strip.png, thumbnail.png, plus the @2x and @3x retina variants).
Missing files on disk are silently skipped — the locale still falls back to
the base (non-localized) image.
How It Works: Google
The LocalizedString shape
Google Wallet's API represents every localizable text as a LocalizedString
object:
{
"defaultValue": {
"language": "en-US",
"value": "Summer Music Festival"
},
"translatedValues": [
{"language": "fr", "value": "Festival de Musique d'Été"},
{"language": "es", "value": "Festival de Música de Verano"}
]
}The library always emits defaultValue with "language": "en-US" and the
source string. When :translations matches the source string, each matching
locale becomes an entry in translatedValues. When nothing matches, the
translatedValues key is omitted (so the JSON stays compact and Google
serves the default).
The plain + localized sibling pattern
Google's API has a pattern: some required fields are plain strings, and the
localized form is an optional sibling field with a localized prefix. We
never replace the required plain field — we add the sibling:
| Required plain field | Localized sibling | Class type |
|---|---|---|
issuerName | localizedIssuerName | All class types |
programName | localizedProgramName | :store_card (loyalty) |
title | localizedTitle | :coupon (offer) |
For these fields, devices with a matching locale see the translated value; the plain field acts as the fallback. The library only adds the sibling when at least one translation matches — otherwise it stays out of the JSON entirely.
Other class fields (eventName, venue.name, venue.address) are already
shaped as LocalizedString natively, so the library just populates their
translatedValues array directly.
Object-level localization
On the pass object (not the class), the library localizes:
- Text modules (
textModulesData): each module gets bothheader/body(plain, the fallback) andlocalizedHeader/localizedBody(LocalizedString, withtranslatedValuespopulated for matching translations). - Image content descriptions:
logo.contentDescription,heroImage.contentDescription, andimageModulesData[].mainImage.contentDescriptionare localized when translations match.
Why issuerName stays plain
Google requires issuerName as a plain string on every class. Replacing it
with a LocalizedString would break the API contract. The
localizedIssuerName sibling is how Google itself solves this — and it's
the pattern this library follows for every required-string field.
Recipes
Recipe 1: Localize field labels
The most common case. You have a pass with English field labels like "Gate" and "Section", and you want French and Spanish translations.
pass_data = %PassData{
serial_number: "ticket-1",
pass_type: :event_ticket,
description: "Concert ticket",
organization_name: "Festival Co",
secondary_fields: [
{"section", "Section", "A"},
{"row", "Row", "12"},
{"gate", "Gate", "West"},
],
}
translations = %{
"fr" => %{
"Section" => "Section",
"Row" => "Rangée",
"Gate" => "Porte",
"West" => "Ouest",
},
"es" => %{
"Section" => "Sección",
"Row" => "Fila",
"Gate" => "Puerta",
"West" => "Oeste",
},
}
# Apple: writes fr.lproj/pass.strings and es.lproj/pass.strings into the ZIP.
{:ok, pkpass} =
WalletPasses.build_apple_pass(pass_data, apple_visual, translations: translations)
# Google: textModulesData entries gain localizedHeader/localizedBody with
# translatedValues for each matching locale.
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
translations: translations,
class_config: %{
id: "concert_class",
issuer_name: "Festival Co",
event_name: "Summer Concert"
}
)Notice that labels and their values are both translated independently — "A"
and "12" aren't translated because numerics are the same in French/Spanish,
but "West" is.
Recipe 2: Localize the event title across class and object
The class-level event_name (Google) and the description/logo_text
(Apple) are usually the same string — your event's marketing name. To
localize it, include the same source string in both the class and object
translations maps:
event_title = "Summer Music Festival"
translations = %{
"fr" => %{
event_title => "Festival de Musique d'Été",
"Festival Co" => "Société du Festival",
},
"es" => %{
event_title => "Festival de Música de Verano",
"Festival Co" => "Sociedad del Festival",
},
}
pass_data = %PassData{
serial_number: "ticket-2",
pass_type: :event_ticket,
description: event_title,
organization_name: "Festival Co",
}
apple_visual = %Apple.Visual{logo_text: event_title}
{:ok, pkpass} =
WalletPasses.build_apple_pass(pass_data, apple_visual, translations: translations)
{:ok, save_url} =
WalletPasses.google_save_url(pass_data, google_visual,
translations: translations,
class_config: %{
id: "summer_2026",
issuer_name: "Festival Co",
event_name: event_title,
translations: translations
}
)The event_title variable appears in three places that all benefit from
localization: Apple's description and logoText (translated via
pass.strings lookup), and Google's class eventName (translated via
translatedValues). One translation entry per locale serves all three.
Note "Festival Co" is translated too — that drives Apple's
organizationName lookup and Google's localizedIssuerName sibling field
in a single shot.
Recipe 3: Per-locale strip image (Apple)
For events where the marketing artwork has baked-in text, you can ship a
locale-specific strip.png. Google Wallet uses one image URI per pass and
doesn't support locale-switched images — this is Apple-only.
WalletPasses.build_apple_pass(pass_data, apple_visual,
translations: %{
"fr" => %{"Summer Music Festival" => "Festival de Musique d'Été"},
"es" => %{"Summer Music Festival" => "Festival de Música de Verano"},
},
localized_images: %{
"fr" => %{
"strip.png" => "priv/static/passes/fr/strip.png",
"strip@2x.png" => "priv/static/passes/fr/strip@2x.png"
},
"es" => %{
"strip.png" => "priv/static/passes/es/strip.png",
"strip@2x.png" => "priv/static/passes/es/strip@2x.png"
}
}
)The library reads each file from disk and packs it at
<locale>.lproj/<filename>. Files that don't exist are silently skipped —
the device then falls back to the base (non-localized) image from
apple_visual.strip_image_path.
The localized images also participate in the manifest.json SHA1 hashes, so
the PKCS#7 signature stays valid across the localized pass.
Recipe 4: Adding a new locale to an existing pass
Wallet passes are immutable bundles — once a .pkpass is on a device, it
stays as-is until the device fetches an update. Adding a locale means
re-issuing the pass with the new translations and prompting devices to
refresh.
# 1. Extend your translations map with the new locale.
translations = %{
"fr" => %{...existing...},
"es" => %{...existing...},
"de" => %{
"Summer Music Festival" => "Sommermusikfestival",
"Gate" => "Tor",
# ...
},
}
# 2. Rebuild the Apple pass with the expanded translations. This produces a
# new .pkpass binary; if your app serves passes via the web-service URL,
# the next time devices poll for updates they'll receive this one.
{:ok, pkpass} =
WalletPasses.build_apple_pass(pass_data, apple_visual, translations: translations)
# 3. Push Apple devices so they fetch the new pass instead of waiting for
# their next polling cycle. notify_apple_devices/1 sends silent APNs
# pushes to every device registered for this serial.
:ok = WalletPasses.notify_apple_devices(pass_data.serial_number)
# 4. Update the Google object so the new translatedValues take effect
# immediately on every device with the pass saved.
{:ok, _object_id} =
WalletPasses.update_google_pass(pass_data, google_visual,
translations: translations,
class_config: %{
id: "summer_2026",
issuer_name: "Festival Co",
event_name: "Summer Music Festival",
translations: translations
}
)Three key points:
- Apple devices won't show new translations until they re-fetch the
pass. The
notify_apple_devices/1call sends a silent push that triggers the next poll. Without it, devices update at their own pace (hours to days). - Google updates are instantaneous. Google's servers render the locale
when each device requests the pass, so updating the object via
update_google_pass/3immediately reaches every saved-pass device the next time it syncs (which Google typically does within minutes). - You can also update the class if you've changed class-level fields
(
eventName,issuerName, venue strings). Passclass_configtoupdate_google_pass/3and the library will idempotently ensure the class is up-to-date.
What's NOT Localized
These fields are deliberately excluded from the localization pipeline:
holder_name/ticketHolderName/passengerName/accountName— proper nouns. The library treats names as data, not display text, and emits them as-is.barcode_message/ barcodesvalue— the encoded barcode payload. Scanners read this; humans don't.serial_number, authentication tokens, web service URLs,passTypeIdentifier,teamIdentifier— identifiers and configuration. Localizing them would break pass lookup.- NFC
message/smartTapRedemptionValue— opaque NFC payload. - Dates, coordinates, color hex strings — the OS formats these using the device's locale automatically.
- Apple
barcodes[].messagespecifically;barcodes[].altTextIS localizable because it's the human-readable label below the code.
If you find yourself wanting to localize one of these, you almost certainly
want a different field. (For example, if you want a localized accessibility
description on the barcode, use barcode_alt_text instead of the encoded
barcode_message.)
Troubleshooting
"My translation isn't showing up"
Run this checklist:
- Does the translation key match the source string exactly? The library
matches by exact string equality (case-sensitive, whitespace-sensitive).
"Gate"in yourPassDatawill NOT match"gate"or"Gate "in your translations map. - Does the locale tag on the device match a tag you shipped? Apple
falls back from
fr-CAtofr.lproj, but does NOT fall back fromfrtofr-CA.lproj. Google requires exact verbatim match. On Apple, prefer bare language tags. On Google, prefer bare language tags too unless you have region-specific content. - Did you re-issue the pass and push devices? (Apple only.) See
"Recipe 4" — silent APNs pushes via
notify_apple_devices/1are required for devices to fetch the new pass; otherwise they update on their own schedule. - Are you looking at the right pass? Apple Wallet caches passes aggressively — delete and re-add the pass to force a fresh fetch when debugging locally.
"Apple device shows English even though I shipped French"
Most common cause: the device's preferred locale doesn't match any
.lproj directory in the pass. Check your iPhone's
Settings → General → Language & Region → iPhone Language (not
"Preferred Language Order"). Apple matches the iPhone language, not the
region.
Second most common cause: you shipped "fr-FR" translations but the device
reports "fr". Apple's fallback works down the specificity chain
(fr-FR → fr), not up. If you ship fr-FR.lproj only, an iPhone set to
plain "French" won't find a match. Default to bare "fr" keys.
"Google shows defaultValue instead of the translation"
Google performs verbatim language-tag matching. If you ship "fr-FR"
entries in translatedValues and the device reports "fr" or "fr-CA",
Google falls back to defaultValue (your English source string).
Use bare language tags for the broadest match. If you need region-specific content, ship BOTH tags:
translations = %{
"fr" => %{"Hello" => "Bonjour"},
"fr-CA" => %{"Hello" => "Salut"},
}"I added translations but the manifest signature failed"
The library hashes every file in the ZIP (including .lproj/ entries) into
manifest.json, then signs the manifest with PKCS#7. If you're seeing
signature errors, your code is probably modifying the ZIP after
build_pkpass/4 returns. Don't repack the ZIP — emit it verbatim to clients.
"The :translations option is being ignored"
Verify the call site is passing it. The most common bugs:
- Calling
build_pkpass/3instead of/4. The/3arity exists for back-compat with code from before localization was added, but it omits the opts entirely. Use/4, or use the top-levelWalletPasses.build_apple_pass/3helper. - Passing translations as
%{}(empty map) for a locale. An empty translations map for a locale produces no.lprojentry — that's intentional, but it can surprise you if your translations are dynamically built and ended up empty. - For Google class fields, putting
:translationsonoptsinstead of insideclass_config. The class builder readsclass_config[:translations]because the class is a single map of configuration. Object-level translations stay onopts.
Validation
The fastest way to verify a localized pass works is to flip your device language before opening the pass. On iOS, Settings → General → Language & Region → iPhone Language. Open Wallet — the pass should display in the new language immediately.
For Google Wallet, change your phone's system language. Google Wallet may take a moment to re-sync the pass.
For automated checks, this library's tests
(test/wallet_passes/apple/builder_localization_test.exs and
test/wallet_passes/google/api_localization_test.exs) demonstrate the
expected ZIP and JSON shapes — use them as references for assertions in
your own consumer tests.
API Reference
The functions that accept or thread the :translations option:
WalletPasses.build_apple_pass/3— top-level helper that creates/retrieves the Apple pass record and builds the.pkpass. Accepts:translationsand:localized_imagesin opts.WalletPasses.google_save_url/3— top-level helper that creates/updates the Google object and returns a Save URL. Accepts:translationsin opts for object-level localization. To localize class-level fields, include:translationsinside the:class_configmap.WalletPasses.update_google_pass/3— updates an existing Google object. Accepts:translationsin opts.WalletPasses.Apple.Builder.build_pkpass/4— low-level Apple builder. Accepts:translationsand:localized_imagesin opts.WalletPasses.Google.Api.build_pass_object/3— builds the Google object (per-pass instance). Accepts:translationsin opts and populateslocalizedHeader/localizedBodyon text modules plus image content descriptions.WalletPasses.Google.Api.build_class_object/1— builds the Google class (shared template). Reads translations fromclass_config[:translations]and populateseventName,venue.name,venue.address, plus the plain + localized sibling pairs (issuerName/localizedIssuerName,programName/localizedProgramName,title/localizedTitle).