Google Wallet

How Google Wallet loyalty cards work, end to end

Classes, objects, the signed save link, image specs, and how updates reach every cardholder at once. Written by a team shipping these daily.

Waya TeamUpdated 18 August 202612 min read

Two records behind every card: the class and the object

A Google Wallet loyalty card is two records, not one. A LoyaltyClass is the template for a whole program: merchant name, background color, logo, hero image, reward wording, and the links on the back of the card. A LoyaltyObject is one customer's card: their name, their QR code, their current stamp count. Each object points at one class by ID and inherits everything on it.

Both IDs live under your issuer ID, which Google assigns when your issuer account is approved. The format is your issuer ID, a dot, then any suffix you choose. We build ours deterministically: the class ID is the loyalty program's UUID with dashes swapped for underscores, and the object ID is that plus the customer's phone number. The same inputs always produce the same ID, so a customer re-enrolling on a new phone overwrites their card instead of creating a second one.

That split decides where every change goes. A stamp lands on the object. A new logo lands on the class. If you put design on the object, you rewrite the branding every time someone buys a coffee, and one bad write corrupts a single customer's card in a way that's hard to spot.

The save link is a signed JWT, not a file

Apple hands the customer a file. Google hands them a URL. There is no .pkpass equivalent on Android: you sign a JSON Web Token with your service account's private key using RS256, and send the customer to pay.google.com/gp/v/save/ followed by that token. One tap saves the card.

The claims are specific. `aud` is "google", `typ` is "savetowallet", `payload` holds the loyalty objects, and `origins` is an allowlist of the domains permitted to launch the save. Ours expires one hour after it is minted. If a save button silently does nothing on a staging domain, check `origins` first — a missing entry fails like a broken button rather than a rejected request.

You can technically define the class inline inside that JWT. We stopped doing that. Inline class creation kept failing Google's JWT-side validation with review-status complaints that the REST API never raised, so now we upsert the class over the REST API first (GET, then POST on a 404, otherwise PATCH) and keep the JWT carrying objects only. The class persists on Google's side between requests, so that's a once-per-design cost, not a per-customer one.

It's also worth keeping off the request that the customer is waiting on. The OAuth token exchange plus a class upsert runs about 600 ms to 1.2 seconds on a cold server. The save JWT only references the class by ID, so it doesn't need that call to finish. We hash the class body, skip the round trip when nothing changed, and run the rest in the background after the response is already on its way.

Hero image, circular logo, one hex color

There are three visual slots and that's it. `programLogo` is the small brand mark, `heroImage` is the wide banner, and `hexBackgroundColor` fills everything else. Send a valid six-digit hex value or Google will reject it; we validate the merchant's color against a regex and fall back to our own green when it fails.

As of August 2026, Google's renderer crops the program logo to a circle. Supply a square source with the mark centered and real padding around it, because a wordmark that reaches the edges loses its ends. When a merchant has no usable logo on file we omit `programLogo` entirely instead of shipping a blank placeholder — Google can't render an image whose source is a data URL, and an empty slot reads better than a gray box. The merchant's name still appears as the issuer name.

The hero slot is a wide strip, roughly 1032 x 336 pixels, so close to 3:1. Our Apple strip is 1125 x 432. Rather than skip the image on Android, we send the same URL and let Google scale-fit it; the sides crop slightly, and since the artwork is centered it survives. Google's published image guidance has changed more than once and the newer direction leans squarer, so verify the current spec before you cut new art.

One small trap worth writing down: every string on the class must be non-empty after trimming. A merchant name of a single space is truthy in your code and a 400 from Google saying the issuer name cannot be empty. Trim first, then fall back to the shop name. Set `contentDescription` on both images too — that's the text a screen reader reads out.

How updates reach the phone, and why Apple's model is different

This is the difference people get backwards, and it changes how you build. On Apple, your server is the source of truth. The signed pass file sits on the phone, the phone registers a push token with your web service, and you send an empty APNs push that tells the device to come back and fetch a fresh pass. Two round trips, and the device initiates the one that carries the data.

On Google, Google is the source of truth. The object lives on their servers and the phone renders their copy. You send an authenticated PATCH to the loyaltyObject endpoint on walletobjects.googleapis.com and you're done: no device registration, no push certificate, nothing to re-sign, no file to host. There's also no waiting for a token. An Apple pass can't be updated until iOS has handed you one, while a Google object is updatable the second it exists.

Then there's fan-out, which is the part that genuinely saves work. PATCH a class and every cardholder on that program updates from a single call, because Google fans it out internally. On Apple, a design change means regenerating and re-signing one pass per holder and pushing each device separately. A merchant switching their card from green to black is one HTTP request on Android and a queue job per customer on iOS.

Messages and deletion follow the same pattern. To put text on a cardholder's screen you POST to the object's addMessage endpoint with `messageType` set to TEXT_AND_NOTIFY; we truncate the header to 80 characters and the body to 240 so it renders cleanly. There's no hard delete for a saved object, either. The only way to neutralize a card already sitting in someone's wallet is to PATCH its state to EXPIRED.

Location is where the two platforms stop being equivalent. You can attach latitude and longitude points to the class or object so the card surfaces near a branch, and we cap that at 10 points. But Google has no counterpart to Apple's relevant text, so you cannot write the line of copy that appears on the lock screen — Android shows its own wording. Waya's Branches screen says that out loud to merchants, because "the same feature on both platforms" would not be true.

Three things that broke for us in production

The worst was `multipleDevicesAndHoldersAllowedStatus`. Leave it unset and Google applies one-holder semantics: the first Google account to tap Save owns that object forever. Every later attempt lands on a generic "Something went wrong" screen — the customer switched Google accounts, replaced their phone, or a family member tried the same card. That was a P0 for us on 30 July 2026. Setting MULTIPLE_HOLDERS fixes it, and it's the correct semantic when your objects are keyed to a phone number rather than a Google account.

Second, a 404 from a PATCH is not a retryable failure. It means the object doesn't exist on Google's side, because it was never created or its class or issuer changed underneath it. We used to treat those as errors and retry them forever. Now a 404 is terminal: we clear the stored object ID on the customer's record and stop counting that customer as reachable for wallet messages. That last part mattered more than it sounds, since stale IDs were quietly inflating our own reachable-customer count.

Third, watch what you do synchronously while a customer stands at the counter. Every avoidable Google round trip on the enrollment request is time the customer spends staring at a spinner with a barista watching. Caching the OAuth token, hashing the class to skip unchanged upserts, and deferring the sync until after the response are unglamorous changes that took seconds off the tap-to-card time.

What this means if you just run a shop

None of the above should ever reach you, and that's the point of using a platform for it. On Waya, your customer scans the QR code by the register, types a first name and a mobile number, and taps once to save the card. Android phones get a Google Wallet pass, iPhones get an Apple Wallet pass, and anything else gets a web card that works in a browser. Your customer installs nothing.

Here's what Waya does not do, stated plainly: no POS or till integration, no extra hardware, and no SMS. Your cashier scans the customer's card on an ordinary phone, and messages go through the wallet itself, so the customer's phone number never gets handed to a third-party messaging app. Waya sits beside whatever till you already use.

Pricing is simple enough to check in a minute. Free is 0 SAR forever, covering up to 100 customers, 100 wallet messages a month, 1 stamp card, and 1 branch, with no credit card. It's a hard stop, not a trial: at 100 customers new enrollments stop, and everyone already enrolled keeps their card and keeps collecting. Growth is 85 SAR a month for unlimited customers, up to 10 cards, and 5,000 messages; Premium is 149 SAR a month. Right now 100+ shops across Saudi Arabia run on this, with 5,000+ cards living in customers' wallets.

Frequently asked questions

What's the difference between a Google Wallet class and an object?

A LoyaltyClass is the shared template for a program — brand name, colors, logo, hero image, and reward text — while a LoyaltyObject is one customer's card, holding their name, QR code, and current stamp count. Every object references exactly one class by ID and inherits its design. Patch the class and every holder's card changes at once; patch an object and only that customer's card does.

Do my customers need the Google Wallet app to use a loyalty card?

Google Wallet comes preinstalled on most Android phones, so the large majority of customers install nothing at all. They tap the save link and the card lands in the wallet they already have, then updates itself from then on. On Waya, anyone whose device or browser can't take a Google Wallet pass gets a web card instead, so nobody is turned away at the counter.

How do I update a Google Wallet pass after the customer has saved it?

Send an authenticated PATCH to the loyaltyObject endpoint on walletobjects.googleapis.com, and Google propagates the change to every device holding that card. There is no device registration and no push certificate, unlike Apple's APNs flow. To change the design for everyone on a program in one call, patch the class instead of the individual objects.

Why does my Google Wallet save link stop working?

The save link is a signed JWT with an expiry, so old links go stale — ours expires one hour after it is minted. The other common cause is the `origins` claim, an allowlist of domains permitted to launch the save; a domain that isn't listed fails in a way that looks like a dead button rather than an error. Mint the link fresh per customer instead of storing it.

Can I remove a loyalty card from a customer's Google Wallet?

There is no hard delete for a LoyaltyObject, so the only way to neutralize a saved card is to PATCH its state to EXPIRED. The card stays visible in the customer's wallet, marked expired, and stops receiving updates. Deleting the row in your own database does nothing to Google's copy, so make the state change first.

Put Waya on your counter — free forever up to 100 customersStart free
Start free