Anatomy of a Google Wallet Pass: Every Field Explained
Where each field on a Google Wallet pass renders, and the JSON behind it. Class vs Object, heroImage, barcodes, the JWT save flow, and the gotchas, with an interactive preview.
First two render on the collapsed card via cardTemplateOverride.twoItems.
A Google Wallet pass looks simple: a colored card with a logo, a banner, some text, a barcode. Underneath, every region on that card maps to a key on a JSON resource you POST to walletobjects.googleapis.com. If you’re issuing Google Wallet passes (loyalty cards, event tickets, boarding passes, coupons), understanding the mapping is the difference between a pass that looks polished and one where the hero image crops badly, the logo silently becomes a letter monogram, or your color choice gets a contrast adjustment you didn’t ask for.
This guide walks through every field on a Google Wallet pass, with the JSON you’d write and where it renders. Try the interactive preview at the top; every region is labelled with its API field name. (If you’re after the same breakdown for the other platform, see our Apple Wallet anatomy guide.)
What is a Google Wallet pass?
A Google Wallet pass is a digital card that lives in the Google Wallet app on Android (and, with QR fallbacks, on iPhone). Google Wallet supports them all under one API: loyalty cards, event tickets, boarding passes, transit passes, coupons, gift cards, and generic membership cards.
Google Wallet passes are REST resources, not files. You POST two JSON resources to Google, a Class (the template, shared across every pass of this type you issue) and an Object (the per-user instance), and hand the user a short signed JWT URL. When they tap it, the Google Wallet app pulls the data from Google’s servers. There is no archive to download, no manifest, no PKCS#7 signature to compute on your server.
Every Google Wallet pass is, ultimately, a row in Google’s database that your server can PATCH. The user doesn’t own an offline file (the Wallet app reads current state from Google whenever it renders the card), and there’s no certificate lifecycle or push-signing infrastructure to maintain on your side.
What you get:
- Server-driven updates. Edit the Object resource, every device that saved the pass refreshes within seconds. No client work.
- Push notifications. Set
messageType: TEXT_AND_NOTIFYon a message and every holder gets an Android notification. - Lock-screen relevance. Geofence locations and time-based reminders surface the pass without the user opening the app.
- Scannable codes. QR, Aztec, PDF417, Code 128, EAN-13, UPC-A, Data Matrix, and more, all rendered natively.
Users add a Google Wallet pass by tapping a “Save to Google Wallet” button, opening a pay.google.com save link, scanning a QR code that points at one, or (in some integrations) through an in-app handoff. Under the hood it’s always the same JWT.
The data model: Class vs Object
Google Wallet splits pass data across two resources, not one.
- The Class is the template. It defines branding, structure, labels, and capabilities that are shared across every pass of this type. Issuer name, program name, brand colors, logos, and the boilerplate “Member perks” text on the back are all class-level.
- The Object is the per-user instance. It carries the data that’s unique to one cardholder. Account ID, name, points balance, barcode value, validity dates, current state.
You create each with a separate REST endpoint:
POST https://walletobjects.googleapis.com/walletobjects/v1/loyaltyClass
POST https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject
You reference the class from the object via classId, and one class can have millions of objects.
Here’s the split for a typical loyalty card:
| Datum | Lives on | Why |
|---|---|---|
| Issuer name (“Bayroast Coffee”) | Class (issuerName) | Same for every member |
| Program name (“Bayroast Rewards”) | Class (programName) | Same for every member |
| Brand color | Class (hexBackgroundColor) | Same for every member |
| Hero banner | Class (heroImage) | Same for every member |
| Program logo | Class (programLogo, wideProgramLogo) | Same for every member |
| ”Member #” label | Class (accountIdLabel) | Label is shared |
| ”AMJ-00042” value | Object (accountId) | Per-user |
| Account holder name | Object (accountName) | Per-user |
| Points balance | Object (loyaltyPoints.balance) | Per-user |
| Barcode value | Object (barcode.value) | Per-user |
| Pass state (ACTIVE/EXPIRED) | Object (state) | Per-user |
Generic passes put more on the Object than other types do. On a GenericObject the logo, the hero image, and the background color all live on the object itself, not the class. The design intent is that two generic passes from the same issuer might look completely different (a one-off concert ticket vs an internal door pass), so the per-user resource carries the per-user look. On Loyalty, Offer, EventTicket, Flight, Transit, and GiftCard, branding lives on the class.
No deletes, ever. Classes and objects cannot be deleted; users can only de-link them from their wallet. If you fat-finger a class id you’re stuck with it. Use a versioning scheme: issuer.brand_loyalty_v2, not just issuer.brand_loyalty.
Pass types
Google Wallet defines seven typed pass categories plus a generic fallback. Each has its own REST resource and its own visual signature.
| Type | REST resource | Best for | Signature visual |
|---|---|---|---|
| Generic | genericClass / genericObject | Anything outside the typed categories (gym memberships, badges, season passes) | Flexible. Author defines labels via header / subheader / textModulesData |
| Loyalty | loyaltyClass / loyaltyObject | Loyalty programs, points cards, membership | Prominent loyaltyPoints balance; optional rewardsTier badge |
| Offer | offerClass / offerObject | Coupons, promo codes, discount offers | Big offer title (“20% off”), provider, finePrint, redemptionChannel enum (INSTORE / ONLINE / BOTH / TEMPORARY_PRICE_REDUCTION) |
| Event ticket | eventTicketClass / eventTicketObject | Concerts, movies, sports, conferences | eventName, venue, seat block (section / row / seat / gate); multiple objects with the same eventId group together |
| Flight (boarding pass) | flightClass / flightObject | Airline boarding passes | Origin → destination as the dominant header (e.g. LHR → JFK) |
| Transit | transitClass / transitObject | Bus, train, metro, ferry | ticketLeg[] with stations and times for multi-segment trips |
| Gift card | giftCardClass / giftCardObject | Stored-value gift cards | cardNumber, optional pin, balance as a Money object |
A separate Generic Private variant exists for sensitive data with stricter handling (think: health records, government IDs).
Choosing a type. Pick the closest match: Loyalty for points programs, Offer for coupons, the typed transit/flight/event/gift types for those use cases. Fall back to Generic for anything else. You can’t change a pass type after creating the class, so decide up front.
Class review for typed passes. Non-Generic classes have a reviewStatus lifecycle: draft → underReview → approved. You can issue passes off a draft class, but only to a small set of test Google accounts on your issuer. You need an approved class to issue to real users; see the demo-vs-production section at the end.
Front of the pass, field by field
Each region on the front of a Google Wallet pass has a specific JSON key and a specific rendering rule.
Most user-facing strings on Google Wallet are not plain strings; they’re LocalizedString objects with a default value and an optional array of per-locale translations:
{
"defaultValue": { "language": "en", "value": "Welcome" },
"translatedValues": [
{ "language": "es", "value": "Bienvenido" },
{ "language": "fr-CA", "value": "Bienvenue" }
]
}
The runtime matches the user’s wallet locale against translatedValues; on a miss it falls back to defaultValue. We’ll write cardTitle as the full object in the first example and shorten to a literal string in the rest; Google’s API accepts the literal string form as syntactic sugar for { defaultValue: { language: "en", value: "..." } }.
Card title and logo
Position: Top row of the pass: small logo on the left, business name immediately to its right.
{
"cardTitle": {
"defaultValue": { "language": "en", "value": "Bayroast Coffee" }
},
"logo": {
"sourceUri": { "uri": "https://example.com/bayroast-logo.png" },
"contentDescription": {
"defaultValue": { "language": "en", "value": "Bayroast Coffee logo" }
}
}
}
cardTitle is the small business-identifier line at the very top, typically your brand name. logo is the round avatar to its left. Both render in every view of the pass: the home grid, the expanded card view, and the swipe-up details.
Logo image specs. PNG, minimum 660×660 px, served over HTTPS with no redirects. Google applies a circular mask, so leave a 15% safe margin around the logo or it will get clipped at the corners. If you don’t supply a logo, the system fills the slot with the first letter of cardTitle in a coloured circle, which is fine as a fallback but surprising the first time you see it.
If you want a wider brand mark (a full wordmark instead of just an icon), use wideLogo (Generic, Offer, EventTicket) or wideProgramLogo (Loyalty). It replaces logo in the top-left and renders at roughly 3.2:1. Recommended dimensions are 1280×400 PNG.
Hero image
Position: Full-width banner across the bottom of the card body. On the expanded card the order top-to-bottom is card-title row → title row (subheader / header) → optional cardTemplateOverride row → barcode → hero image.
JSON key: heroImage
{
"heroImage": {
"sourceUri": { "uri": "https://example.com/coffee-hero.png" },
"contentDescription": {
"defaultValue": { "language": "en", "value": "Latte art" }
}
}
}
The hero image renders at 100% of the card width with proportional height, and sits as a self-contained strip at the bottom of the card body with no text overlaid on it. Google’s own pass-builder and marketing samples both confirm this placement, beneath the title row and beneath the barcode when both are present.
Hero image specs. PNG, recommended 1032×336 px, aspect ratio 3:1 or wider. Oversized images crop, undersized images get upscaled and look blurry. Use the exact dimensions or expect distortion.
Bonus side-effect: if you don’t set hexBackgroundColor, Google derives the card background from the dominant color of your logo, not the hero image. If you want a specific background, set the color explicitly; see the colors section below.
Header and subheader
Position: Title row, immediately below the card-title row. header is the bold main line; subheader sits directly above it as a smaller line of context.
{
"header": {
"defaultValue": { "language": "en", "value": "Alex McJacobs" }
},
"subheader": {
"defaultValue": { "language": "en", "value": "Member" }
}
}
The pattern is: subheader is the label, header is the value. For a loyalty card the typical reading is subheader: "Member", header: "Alex McJacobs". For a coupon you might have subheader: "Save", header: "20% off any drink". For an event ticket subheader: "Section 112 · Row J", header: "Seat 14".
Keep them short. Long values truncate with an ellipsis on small screens, and the title row is where the user’s eye lands first.
Card-row template (optional)
On the collapsed card view (the version of the pass that shows in the user’s Wallet home grid), only the title row is visible by default. If you want extra data to appear there without the user having to tap into the details view, use classTemplateInfo.cardTemplateOverride.
The most common pattern is a 2-up row referencing two of your textModulesData entries:
{
"classTemplateInfo": {
"cardTemplateOverride": {
"cardRowTemplateInfos": [{
"twoItems": {
"startItem": {
"firstValue": {
"fields": [{ "fieldPath": "object.textModulesData['points']" }]
}
},
"endItem": {
"firstValue": {
"fields": [{ "fieldPath": "object.textModulesData['contacts']" }]
}
}
}
}]
}
}
}
That renders a POINTS | CONTACTS two-column row on the collapsed card, pulling values from the object-level textModulesData entries with matching ids. There are oneItem, twoItems, and threeItems variants.
This is how you surface supplementary key/value pairs on the collapsed card without making the user expand it. It’s entirely template-driven and entirely opt-in: with no override, the collapsed card shows only header, subheader, and hero.
Barcode
Position: In the expanded details view by default. Can also be surfaced on the collapsed card with a template override.
JSON key: barcode (on the Object)
{
"barcode": {
"type": "QR_CODE",
"value": "MEMBER-12345-GOLD",
"alternateText": "MEMBER-12345-GOLD"
}
}
Supported formats:
| Format | JSON value | Best for |
|---|---|---|
| QR Code | QR_CODE | Most use cases. Compact, fast, supported everywhere |
| PDF417 | PDF_417 | Legacy scanners; airline boarding passes; rotating barcodes |
| Aztec | AZTEC | Transit systems |
| Code 128 | CODE_128 | Retail POS, simple numeric codes |
| Data Matrix | DATA_MATRIX | Industrial, small-format codes |
| EAN-13 / EAN-8 | EAN_13 / EAN_8 | Product / retail |
| UPC-A | UPC_A | Retail (North America) |
| ITF-14 | ITF_14 | Packaging |
| Codabar | CODABAR | Libraries, blood banks, legacy retail |
| Code 39 | CODE_39 | Industrial, automotive, defense |
| Text only | TEXT_ONLY | Render a code as text with no scannable graphic |
alternateText is the human-readable string under the barcode: what someone behind a counter types when the scanner fails. Default is to render value itself; override it if value is opaque (a long base64 token, say). One exception: alternateText is not supported with TEXT_ONLY, because there’s no scannable graphic to label.
Rotating barcodes for time-based, replay-resistant codes:
{
"rotatingBarcode": {
"type": "QR_CODE",
"valuePattern": "MEMBER-12345-{totp_value_0}",
"totpDetails": {
"algorithm": "TOTP_SHA1",
"periodMillis": "30000",
"parameters": [{
"key": "EXAMPLEBASE16SECRET",
"valueLength": 6
}]
}
}
}
Only QR_CODE and PDF_417 are supported for rotating barcodes. The valuePattern substitutes {totp_value_n} for an RFC 6238 TOTP code using the matching parameters[n] secret. Use this when you need a barcode that expires every 30 seconds: gate scanners at venues, transit, anything where a screenshot of the pass shouldn’t grant ongoing access.
The details view
Tap a Google Wallet pass and it expands into a scrollable details view. There are no layout constraints, no truncation, and several typed modules you can drop in.
Text modules
Header/body pairs of arbitrary text:
{
"textModulesData": [
{
"id": "perks",
"header": "Member perks",
"body": "Free shipping on every order, early access to new drops."
},
{
"id": "phone",
"header": "Customer service",
"body": "+1 (555) 123-4567"
}
]
}
Up to 10 from the Class plus up to 10 from the Object; Google merges and renders them in details order. Each entry needs a stable id if you want to reference it from cardTemplateOverride (to surface it on the collapsed card) or detailsTemplateOverride (to control its ordering and visibility on the expanded view).
Auto-linking works for URLs, phone numbers, and email addresses in the body field, where they become tappable. There is no Markdown or HTML support, just plain text with smart detection.
Image modules
Inline images in the details view, such as banner photos for an event, a venue map, or a product shot:
{
"imageModulesData": [{
"id": "venue_map",
"mainImage": {
"sourceUri": { "uri": "https://example.com/venue-map.png" },
"contentDescription": {
"defaultValue": { "language": "en", "value": "Venue map" }
}
}
}]
}
Limits differ by pass type. Generic allows 10 on the class plus 10 on the object. Loyalty caps at 1 + 1. Check the specific resource docs before assuming you can pack a gallery.
Links module
A flat list of buttons in the details view, such as your website, terms link, support number, or app deeplink:
{
"linksModuleData": {
"uris": [
{
"id": "site",
"uri": "https://bayroast.example.com",
"description": "Visit our website"
},
{
"id": "support",
"uri": "tel:+15551234567",
"description": "Call support"
}
]
}
}
Up to 10 on the class plus 10 on the object. URIs can be https://, tel:, mailto:, or an Android deeplink; Google renders them as buttons grouped by scheme.
Messages
A class- or object-level inbox channel. Messages render as text in the details view, and (with the right messageType) can also fire an Android notification:
{
"messages": [{
"header": "Spring drinks are back",
"body": "The seasonal menu is live for members. Tap to order ahead.",
"messageType": "TEXT_AND_NOTIFY",
"displayInterval": {
"start": { "date": "2026-03-01T00:00:00Z" },
"end": { "date": "2026-04-30T23:59:59Z" }
}
}]
}
messageType: TEXT puts the message in the details view silently. messageType: TEXT_AND_NOTIFY does the same and fires a one-shot notification. Up to 10 active messages per class/object.
A message on the class reaches every holder of every pass off that class, while a message on the object reaches one specific user. Use displayInterval to time-box visibility.
Security animation
A holographic-style animation Google can render over the pass, typically used by loyalty programs to make screenshots obviously fake to a barista or doorperson:
{
"securityAnimation": {
"animationType": "FOIL_SHIMMER"
}
}
Class-level only. One of those small details that’s free to add and meaningfully improves on-the-counter trust.
How passes update after install
Google Wallet’s update model is server-driven. You PATCH the object on Google’s API, Google fans the change out to every device that saved it, and you don’t run any per-device push infrastructure.
curl -X PATCH "https://walletobjects.googleapis.com/walletobjects/v1/loyaltyObject/$OBJECT_ID" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"loyaltyPoints": {
"label": "Points",
"balance": { "int": 1500 }
}
}'
Within seconds (not an SLO, but reliably fast in practice), every device that saved the object reflects the new balance.
What the user sees depends on three opt-in mechanisms:
notifyPreferenceon the field’s containing resource. When set toNOTIFY_ON_UPDATEand you change a field configured to notify, Google fires an Android notification on the field change itself. Off by default.messages[].messageType: TEXT_AND_NOTIFY. Add a message to the class or object to push an explicit banner. Best for “your balance just hit Gold tier” moments.- Auto-notifications keyed off time fields, set per object via
notifications.expiryNotification.enableNotification(fires 2 days before validity end) andnotifications.upcomingNotification.enableNotification(fires 1 day before validity start). If both are enabled, expiry takes precedence. Event tickets, flights, and offers also get type-specific reminders out of the box: an event ticket reminder ~3 hours before the event, a flight delay/gate alert keyed off scheduled departure, an offer expiration reminder ~48 hours before.
Arbitrary developer-authored push notifications are not supported. The FAQ states this directly. The closest thing to an ad-hoc push is a TEXT_AND_NOTIFY message, but you’re still subject to Google’s per-class quotas and the notification is framed as a message, not a system alert.
Geofence triggers
Google Wallet supports up to 10 geofence locations per pass via merchantLocations (on the class or object):
{
"merchantLocations": [{
"latitude": 40.7484,
"longitude": -73.9857
}]
}
When the device enters the implicit radius, Google surfaces the pass on the lock screen and notification shade. Don’t expect instant entry detection; expect minutes, not seconds.
The cap was historically 10 locations per pass; at Google I/O 2026, the Nearby Passes feature loosened that to surface relevant passes through Google Maps without consuming the per-pass quota.
Colors and branding
{
"hexBackgroundColor": "#15803d"
}
Color is a single hex string.
Strict format. #rrggbb: six lowercase or uppercase hex digits, no alpha, no shorthand (#fff is invalid), no named colors. Wrong format errors out the create call.
Text color is automatic. Google picks white or black for legibility against your background. There is no foregroundColor or labelColor you can override. Pick a background that contrasts cleanly with one or the other; mid-tone grays look mediocre because Google can’t choose well.
Fallback. If you omit hexBackgroundColor, Google’s brand guidelines describe the algorithm: it analyzes your logo, finds its dominant color, and uses that for the background. The hero image is not part of the fallback chain; only the logo is.
No light/dark mode override. The Google Wallet app chrome adapts to the system theme. The pass body uses your color in both modes; your dark teal looks the same on a black system background as on a white one. If you care about appearance in both modes, pick a color that survives both.
With one color knob and Google picking the text color for you, it’s hard to ship an unreadable pass.
Adding a pass to a wallet: the JWT flow
The “Save to Google Wallet” handoff is a signed JWT URL.
The flow:
- Your server has a Google Cloud service account with the Wallet Objects API enabled.
- You construct an unsigned JWT that names the object (or class + object) you want to save.
- You sign it with the service account’s private key (RS256).
- You generate a URL of the form
https://pay.google.com/gp/v/save/{signed_jwt}and present it as a “Save to Google Wallet” button. - The user taps the button; on Android the URL hands off to the Wallet app, on desktop it shows a QR code that the user scans with their phone, on iOS it opens a web fallback.
The JWT payload:
{
"iss": "[email protected]",
"aud": "google",
"typ": "savetowallet",
"iat": 1735689600,
"origins": ["https://your-site.example.com"],
"payload": {
"loyaltyObjects": [{
"id": "3388000000022000000.member_alex_42",
"classId": "3388000000022000000.bayroast_loyalty",
"state": "ACTIVE",
"accountName": "Alex McJacobs",
"accountId": "AMJ-00042",
"loyaltyPoints": {
"label": "Points",
"balance": { "int": 1250 }
},
"barcode": {
"type": "CODE_128",
"value": "AMJ-00042"
}
}]
}
}
iss must be the service-account email, not your personal Gmail. aud is literally "google". typ is literally "savetowallet". origins is an optional allow-list of domains permitted to host the save link.
The payload object can carry either fully inlined class+object definitions (handy for one-shot demos, since Google will create the resources on the fly) or just IDs of resources you’ve already created via the REST API. Production issuers almost always pre-create the class via REST and put just the object (or even just the object’s id and classId) in the JWT.
The “Add to Google Wallet” button itself has its own brand rules: black-only with two layouts (primary and condensed), 48 dp minimum height, 8 dp clear-space, localized into 40+ languages. Don’t recolour it.
Common footguns
A list of things that bite first-time Google Wallet issuers:
- No deletes. Classes and objects exist forever. Pick a versioning scheme for IDs.
- No personal images on passes. GDPR-driven. No user-uploaded selfies on a member card.
- Logos must be PNG, HTTPS, no redirects. A broken logo silently falls back to the first letter of
cardTitle. - Hero image dimensions matter. Off-spec sizes crop or upscale unflatteringly. Use 1032×336 PNG.
hexBackgroundColoris strict#rrggbb. Three-digit shorthand, alpha channels, and named colors all error.- Demo vs production gating. New issuer accounts are demo-only and can issue passes only to a small set of test Google accounts on the issuer. To go live: complete the Business Profile, create at least one class, click “Request publishing access” in the Wallet console, wait for human review. Until approved, real users get a
[TEST ONLY]banner on every pass. - Class
reviewStatusfor typed (non-Generic) passes followsdraft→underReview→approved. You can’t issue real passes off a draft class. - Rate limit 20 RPS. Google recommends a 10-second client timeout; p99 latency is around 5 seconds.
- Character limits to watch (recommended maxes from the docs, not hard rejections, but anything longer ellipsises or truncates badly):
issuerNameandprogramName20;accountIdLabel/accountNameLabel15;rewardsTierLabel9;offer.title60 withshortTitle20 andprovider12. groupingInfocontrols how multiple passes from the same issuer stack visually for one user. Easy to forget; defaults often aren’t what you want.appLinkDataprecedence: object beats class.- No developer push. Use
messageswithTEXT_AND_NOTIFYor rely on auto-notifications keyed off time fields.
Where this leaves you
Google Wallet’s data model is declarative. You POST a Class, POST an Object, sign one JWT for the save link, and PATCH the Object whenever something changes. The platform handles the rest: rendering, push to devices, geofence triggers, lockscreen surfaces.
It’s also an opinionated platform. You don’t pick text colors, you don’t choose between visual styles with different field layouts, and the pass isn’t a file the user owns; every pass is a row in Google’s database that your server can edit and Google’s app renders. For most issuers (loyalty, tickets, coupons, boarding passes), that’s a fair trade.
The WalletWallet API today focuses on Apple Wallet: signed .pkpass files, server-driven updates via APNs, the certificate lifecycle handled for you. Google Wallet support is on the roadmap. Sign up if you want the heads-up when we ship it.
Written by Alen Todorov, founder of WalletWallet API — the tech behind the consumer product WalletWallet, which was featured on Product Hunt, hit the Reddit frontpage, and has generated over 100,000 passes.