All articles
// developers

One Codebase, Both Wallets: A Single Pass Model for Apple and Google

Apple wants a signed file. Google wants an authenticated API call. The internal schema that feeds both without forking your codebase in two.

Updated
One Codebase, Both Wallets: A Single Pass Model for Apple and Google
Julio Song
11 min read

Marcus shipped Apple Wallet membership cards for his gym chain two months ago, and it went about as well as one of these projects can go. The signing pipeline is stable, the barcode scans at every front desk, and the support tickets about a vanished pass have mostly stopped. Then a regional manager asks when Android members get the same card, and the deadline is two weeks out. Marcus opens Google's Wallet documentation expecting something like pass.json with different names on the same fields. What he finds instead doesn't share a single property name with what he already built.

That's not a hidden detail of Google Wallet. It's the first thing anyone building both platforms runs into. Apple wants a signed file, mailed to a phone through a certificate chain you maintain yourself. Google wants an authenticated REST call describing an object that already lives on Google's own servers. The instinct at that point is to build the Android side as a second, unrelated codebase, because nothing on the page in front of you looks reusable. It is reusable. The two formats disagree about almost everything except what a membership card, a boarding pass, or a coupon actually is, and that idea is the one piece worth writing once.

Two field arrays chasing the same idea

Apple's pass.json organizes a pass into named field groups: headerFields, primaryFields, secondaryFields, auxiliaryFields, and backFields, each an array of small dictionaries with a key, a label, and a value.

{
  "primaryFields": [{ "key": "balance", "label": "BALANCE", "value": "$42.00" }],
  "auxiliaryFields": [{ "key": "tier", "label": "TIER", "value": "Gold" }]
}

Google's GenericObject gets there with a different shape entirely. Instead of named zones on the front of a card, you hand it a flat list of textModulesData entries, each with an id, a header, and a body, plus a separate linksModuleData for anything clickable and imageModulesData for supporting art. Google caps the visible count at ten fields from the class and ten more from the object, rather than by zone.

Neither shape is wrong. They're two answers to the same question: what does this card say, and where does it say it? The mistake is handing either format directly to the code that decides what a pass should display. Write one internal shape instead, a plain fields: { label: string, value: string }[], and two small adapters that turn it into Apple's zoned dictionaries or Google's flat modules on the way out. Your business logic never needs to know that one platform calls it a zone and the other calls it a module.

The zones do carry one real constraint worth keeping in your schema rather than hiding in an adapter. Apple's primaryFields and headerFields are meant to hold at most one or two entries each, since that's what fits the visual layout of a physical card, while backFields can hold as many as you want because nobody sees them until they tap through. Google has no equivalent front-and-back split. Everything in textModulesData renders on the object's detail screen, in the order you send it. If your schema doesn't distinguish "shown up front" from "shown on request," the Google adapter has no way to decide what belongs near the top of the list, so that distinction has to live in the shared model even though only one platform enforces it visually.

Testing the mapping is cheaper than testing the pass. Once both adapters exist, a handful of fixture objects run through each and get diffed against a known-good pass.json and a known-good GenericObject payload, which catches a broken field mapping in a unit test instead of during a support ticket about a blank line on a customer's phone.

Two ways of proving the pass is yours

Signing is where the two platforms stop rhyming entirely. Apple's chain runs through a Pass Type ID certificate you renew every year, Apple's WWDR intermediate certificate, and a detached PKCS#7 signature computed over a manifest of every file in the .pkpass archive. None of that has an equivalent on the other side.

Google skips files and certificates altogether. You authenticate with a service account key and sign a JWT, using the RS256 algorithm, that carries either the pass payload itself or a reference to an object you already created through Google's REST API. There's no manifest to hash and no intermediate certificate to track, because there's no file changing hands. The JWT is handed to a "Save to Google Wallet" link, and Google's own servers do the rest.

This is exactly why signing has to live in the adapter layer and nowhere near your shared schema. A schema field for "the organization's Pass Type ID" or "the service account key" would only ever be read by one of the two adapters, which is a sign it doesn't belong in the shared model at all. Keep the certificate chain and the JWT signing inside their own small modules, each responsible for turning your internal pass into whatever the wire format on that side requires, and the schema itself never has to know either platform's authentication story exists.

Give both modules the same narrow contract instead: take an internal pass, return either a signed .pkpass buffer or a signed JWT string, and raise a typed error when the credentials behind them are missing or expired. Your certificate expires once a year and needs a renewal calendar of its own, while a service account key rotates on whatever schedule your security team picks. Neither event should touch the code that decides what a membership card says, and a narrow contract is what keeps a certificate renewal from turning into a multi-file pull request.

The field that will not translate

Every field mapping problem eventually produces one exception ugly enough to earn its own section, and for a shared pass schema it's expiration. The obvious design maps an internal expiresAt timestamp straight onto Apple's expirationDate and Google's validTimeInterval.end.date, and on Google's side that works precisely as documented: once the date passes, the pass moves to the "Expired passes" list within about 24 hours, or you can force it immediately by setting the object's state to EXPIRED.

On Apple's side, that same mapping quietly fails. Since an iOS update in mid-2022, Wallet has treated a separate field, relevantDate, as the real trigger for hiding a pass, and it ignores expirationDate for that purpose even when the two are set to different values. Developers reported multi-day event tickets and guest passes disappearing from Wallet a full day early on Apple's own developer forums, tracing the cause back to relevantDate rather than the expiration field they'd set. The field exists to tell Wallet when a pass becomes relevant for lock screen notifications, not when it becomes invalid, and Apple never documented the overlap between the two behaviors.

Apple then moved the target again. As of iOS 18.1, the singular relevantDate is deprecated in favor of a relevantDates array of startDate and endDate pairs, and reports on Apple's own forums describe lock screen notifications that worked under the old field going quiet under the new one until the pass is updated to the array form. None of that churn should ever reach your schema. It's exactly why this behavior belongs in a small, replaceable adapter function instead of scattered through whatever code happens to construct a pass.

A schema that maps one internal concept to one field per platform breaks on exactly this kind of mismatch, and keeps breaking as the platform itself changes underneath it. The fix isn't a cleverer field name. It's accepting that "when does this pass stop mattering" needs two independent adapter behaviors: set expirationDate and also carry the same timestamp into Apple's relevance field, whichever shape that field currently takes, and set validTimeInterval alongside an explicit state transition on Google's. The shared schema holds one timestamp. The adapters decide, separately and on their own upgrade schedule, what each platform does with it.

What actually maps cleanly

Not everything is this fraught, and it's worth saying so before the certificate chains and the date fields make the whole exercise sound hopeless. Barcodes are close to a direct translation. Apple's barcodes array takes a format (PKBarcodeFormatQR, PKBarcodeFormatPDF417, PKBarcodeFormatAztec, or PKBarcodeFormatCode128), a message, and an encoding. Google's barcode object on a GenericObject takes a type (QR_CODE, PDF_417, AZTEC, CODE_128, and a handful more) and a value, covering the same set of formats under different constant names.

An internal barcode: { format: "qr" | "pdf417" | "aztec" | "code128", value: string } maps to both sides with a lookup table and nothing more clever than that. The organization name, the logo, and the background color follow the same pattern: different property names, the same underlying idea, no platform-specific behavior hiding underneath. Spend your design effort on the fields that diverge, expiration among them, rather than treating every field as equally risky.

Push versus poll

Getting a pass onto a phone once is the easy half. Updating it afterward is where the two platforms stop resembling each other again. Apple's model needs a server of your own: a device registers itself against your webServiceURL, you push an empty, contentless notification through Apple's Push Notification service when something changes, and the device calls back into your endpoints to ask what's new and fetch a freshly signed pass.

Google needs none of that infrastructure. You call PATCH on the object through Google's REST API, and Google's own servers push the change to the device. There's no push token to store, no registration table to maintain, and no silent notification payload to construct. The tradeoff is that you're now depending on Google's delivery timing instead of your own, where Apple's model at least puts the retry logic in your hands.

An internal pushUpdate(pass) function has to hide both of these behind one call site, but the two implementations behind it share essentially no code. One talks to APNs and your own device registrations, the other talks to a single Google endpoint and stops. That asymmetry is fine. The point of the shared interface isn't that both sides do the same work, it's that the code calling pushUpdate never has to know which one it's talking to.

The place this pays off is your own application code, wherever a loyalty balance or a gate number actually changes. That code should call one function, pushUpdate(passId), after writing to your database, and never branch on which wallet the customer chose. Bury the branch in the adapter layer once, and every future feature that touches a pass, a new loyalty tier, a rebooked flight, gets both platforms for free instead of needing a reminder to update the Android path too.

Google already tried to solve this, and stopped short

You aren't the first team to want one format that covers both wallets, and it's worth knowing that Google itself tried. In November 2022, Google's developer relations team published an open source Pass Converter, a tool built specifically to take a pass designed for one wallet and produce the equivalent pass for the other, covering event tickets, loyalty cards, coupons, boarding passes, and transit passes.

Even Google's own converter didn't attempt a single universal wire format. It still asks for your own Apple Pass Type ID certificate and WWDR file, the same credentials any direct integration needs, and its config carries a hints mapping specifically because Apple's freeform PassFields don't line up with Google's structured properties on their own. That's the platform vendor with the most reason to make this look automatic, and it still landed on a converter that needs your certificates and a manual field mapping, rather than one format both sides simply understand. Treat that as confirmation, not as a reason to reach for the tool instead of your own model. A converter earns its keep migrating an existing pass library once. A schema earns its keep every time you ship a new pass type.

Where the schema should stop

The temptation once you've built one internal shape is to keep growing it until it can represent every option either platform exposes, smart tap, security animations, app link data, and every other flag buried in the reference docs. Resist that. The moment your shared schema needs a field that only one adapter ever reads, it has stopped being shared and started being Apple's format with Google's format bolted onto the side.

Keep it to what a pass needs across both wallets: a handful of labeled fields, a barcode, an organization identity, an expiration behavior, and a way to trigger an update. Let each adapter reach past the schema for anything genuinely platform-specific, using its own configuration rather than crowding the shared model. That boundary is what keeps a second platform from meaning a second codebase the next time one shows up.

If you'd rather not build and maintain both adapters yourself, Passmint issues and updates Apple and Google Wallet passes from one API, so the field mapping, the certificate chain, and the JWT signing all happen behind a call your server already makes.

Primary sources

Common questions

Yes, for the fields the two platforms share: labeled text, a barcode, an organization identity. Signing, certificates, and the update mechanism differ completely between platforms and should stay in separate adapter code rather than the shared model.
Apple Wallet has used a separate field, relevantDate (and its replacement, the relevantDates array since iOS 18.1), to control lock screen relevance and, undocumented, pass visibility. A value there earlier than expirationDate can hide a pass before it should expire.
No. Updating a Google Wallet object is a single authenticated PATCH call to Google's REST API, and Google's own servers deliver the change to the device. There is no APNs equivalent, no device token to store, and no registration endpoint to build.
Google publishes an open source Pass Converter on GitHub. It still requires your own Apple signing certificates and a hints mapping in its config file, since the two formats do not line up automatically.
Julio Song

Technical content writer, Passmint

Julio is a technical content writer at Passmint. He writes about Apple PassKit, the Google Wallet API, and what breaks when wallet passes meet production traffic.

More from Julio Song

Related reading

Share this article