Skip to main content

guides/localization.md

# 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`).