# Localization
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 pass `description`, the `organization_name`, the
`logo_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 `.lproj`
directories 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`, NFC `message`,
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.
```elixir
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` → tries `fr-CA.lproj` → falls back to `fr.lproj` →
falls back to the raw `pass.json` value (English).
- User locale `fr-FR` → tries `fr-FR.lproj` → falls back to `fr.lproj` →
falls back to the raw `pass.json` value.
- User locale `en-US` → no `.lproj` matches → uses the raw `pass.json`
value.
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:
```json
{
"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`, and
`changeMessage` — across `headerFields`, `primaryFields`, `secondaryFields`,
`auxiliaryFields`, and `backFields`.
- 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:
```elixir
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:
```json
{
"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 both `header`/`body`
(plain, the fallback) and `localizedHeader`/`localizedBody`
(`LocalizedString`, with `translatedValues` populated for matching
translations).
- **Image content descriptions**: `logo.contentDescription`,
`heroImage.contentDescription`, and `imageModulesData[].mainImage.contentDescription`
are 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.
```elixir
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:
```elixir
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.
```elixir
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.
```elixir
# 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:
1. **Apple devices won't show new translations until they re-fetch the
pass.** The `notify_apple_devices/1` call sends a silent push that
triggers the next poll. Without it, devices update at their own pace
(hours to days).
2. **Google updates are instantaneous.** Google's servers render the locale
when each device requests the pass, so updating the object via
`update_google_pass/3` immediately reaches every saved-pass device the
next time it syncs (which Google typically does within minutes).
3. **You can also update the class** if you've changed class-level fields
(`eventName`, `issuerName`, venue strings). Pass `class_config` to
`update_google_pass/3` and 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` / barcodes `value`** — 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[].message`** specifically; `barcodes[].altText` IS
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:
1. **Does the translation key match the source string exactly?** The library
matches by exact string equality (case-sensitive, whitespace-sensitive).
`"Gate"` in your `PassData` will NOT match `"gate"` or `"Gate "` in your
translations map.
2. **Does the locale tag on the device match a tag you shipped?** Apple
falls back from `fr-CA` to `fr.lproj`, but does NOT fall back from `fr`
to `fr-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.
3. **Did you re-issue the pass and push devices?** (Apple only.) See
"Recipe 4" — silent APNs pushes via `notify_apple_devices/1` are required
for devices to fetch the new pass; otherwise they update on their own
schedule.
4. **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:
```elixir
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/3` instead of `/4`.** The `/3` arity exists for
back-compat with code from before localization was added, but it omits the
opts entirely. Use `/4`, or use the top-level
`WalletPasses.build_apple_pass/3` helper.
- **Passing translations as `%{}` (empty map) for a locale**. An empty
translations map for a locale produces no `.lproj` entry — that's
intentional, but it can surprise you if your translations are dynamically
built and ended up empty.
- **For Google class fields**, putting `:translations` on `opts` instead of
inside `class_config`. The class builder reads `class_config[:translations]`
because the class is a single map of configuration. Object-level
translations stay on `opts`.
## 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 `:translations`
and `:localized_images` in opts.
- `WalletPasses.google_save_url/3` — top-level helper that creates/updates
the Google object and returns a Save URL. Accepts `:translations` in opts
for object-level localization. To localize class-level fields, include
`:translations` inside the `:class_config` map.
- `WalletPasses.update_google_pass/3` — updates an existing Google object.
Accepts `:translations` in opts.
- `WalletPasses.Apple.Builder.build_pkpass/4` — low-level Apple builder.
Accepts `:translations` and `:localized_images` in opts.
- `WalletPasses.Google.Api.build_pass_object/3` — builds the Google
*object* (per-pass instance). Accepts `:translations` in opts and
populates `localizedHeader`/`localizedBody` on text modules plus image
content descriptions.
- `WalletPasses.Google.Api.build_class_object/1` — builds the Google
*class* (shared template). Reads translations from
`class_config[:translations]` and populates `eventName`, `venue.name`,
`venue.address`, plus the plain + localized sibling pairs
(`issuerName`/`localizedIssuerName`, `programName`/`localizedProgramName`,
`title`/`localizedTitle`).