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.

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:

{
  "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:

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 fieldLocalized siblingClass type
issuerNamelocalizedIssuerNameAll class types
programNamelocalizedProgramName:store_card (loyalty)
titlelocalizedTitle: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.

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:

  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-FRfr), 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/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).