Introduction
Your customers already carry everything on their phones — and their native digital wallet apps (Apple Wallet on iOS and Google Wallet on Android) are accessed daily. Building a production-grade system that issues real digital passes transforms customer engagement: digital loyalty cards, tickets, and membership cards that users add with a single tap, receive live dynamic updates on their lock screens, and scan at physical locations to redeem rewards or verify admission.
This guide provides an end-to-end, production-tested blueprint for architecting and implementing a dual-platform wallet pass engine. The concepts, cryptographic signing pipelines, API specifications, and database models presented here are deliberately stack-agnostic — applicable whether your backend is built with TypeScript/Node.js, Go, Python, Java, or Rust, and whether your database is PostgreSQL, MySQL, or a document store.
By the end of this guide, you will master:
- Google Wallet Architecture: From Google Cloud service account provisioning and Google Pay Console issuer setup to signing JWT passes and handling class inheritance.
-
Apple Wallet & PassKit Architecture: Managing certificate authorities,
.p12to.pemextraction, WWDR G4 chains, APNs push channels, and strict PassKit validation rules. - Local Development & Mobile Testing via HTTPS Tunneling (ngrok): How to satisfy Apple’s non-negotiable HTTPS requirement and Google’s JWT origin validation when testing on physical smartphones.
- Pass Design & Cryptographic QR Redemptions: Structuring fields, image bundles, and tamper-proof, short-lived QR codes.
- Multi-Tenant System Design: How a single platform issuer can host hundreds of individual brands with their own custom colors, logos, and points systems without requiring separate Apple or Google developer accounts.
- Customer Identity & Junction Data Modeling: Handling customers who belong to multiple businesses simultaneously without data leaks or duplicate pass collisions.
📂 Reference Implementation & Open Source Repository:
A complete, production-ready reference implementation of this architecture — featuring a NestJS backend, PostgreSQL/Prisma persistence, PassKit signing pipeline, and a Vue 3 Tailwind admin dashboard — is available on GitHub:
👉 https://github.com/gikwegbu/add_to_google_apple_walletContributions, issue reports, and pull requests are warmly welcome!
Part 1 — Google Wallet: From Zero to Issuer
Google Wallet passes are powered by the Google Wallet REST API and authenticated using JSON Web Tokens (JWTs) signed with RS256 using an asymmetric Google Service Account key.
┌────────────────────────────────────────────────────────────────────────┐
│ GOOGLE WALLET ECOSYSTEM │
├────────────────────────────────┬───────────────────────────────────────┤
│ GOOGLE CLOUD CONSOLE │ GOOGLE PAY & WALLET CONSOLE │
│ - Cloud Project │ - Issuer Account (Issuer ID) │
│ - Google Wallet API Enabled │ - Service Account Authorization │
│ - Service Account (RSA Key) │ - Pass Class Definitions │
└────────────────────────────────┴───────────────────────────────────────┘
1.1 Create a Google Cloud Project
Every Google Wallet integration lives under a Google Cloud Project:
- Navigate to the Google Cloud Console.
- Create a new project (e.g.,
wallet-pass-platform). Choose a name reflecting your platform rather than a single client brand. - Ensure billing is enabled for the project.
1.2 Enable the Google Wallet API
- In your Cloud Project, open APIs & Services → Library.
- Search for Google Wallet API.
- Click Enable. This activates the endpoints under
walletobjects.googleapis.com.
1.3 Provision a Dedicated Service Account
Your backend server authenticates with Google using an automated Service Account identity:
- Open IAM & Admin → Service Accounts.
- Click Create Service Account, naming it descriptively (e.g.,
wallet-issuer-sa). - Click into the newly created service account and navigate to the Keys tab.
- Select Add Key → Create new key → JSON.
- Download the JSON credential file and store it securely on your server (e.g.,
core/service-account.json). Never commit this file or its private key to version control.
The downloaded file contains:
-
client_email: The service account email (e.g.,wallet-issuer-sa@project-id.iam.gserviceaccount.com). -
private_key: The RSA 2048-bit private key (PEM format with\ncharacters).
1.4 Register in the Google Pay & Wallet Console
Having the Cloud Project API enabled is not enough. You must register as an authorized pass issuer:
- Go to the Google Pay & Wallet Console.
- Complete the business registration profile (Legal Business Name, Website, Point of Contact).
- Once approved, note your Issuer ID (a numeric string such as
3388000000023066256). This permanent identifier namespaces every class and object you ever produce.
1.5 Authorize the Service Account in the Wallet Console
The Cloud Console and the Pay & Wallet Console are independent security domains. You must bridge them:
- In the Google Pay & Wallet Console, navigate to the Users tab.
- Click Invite a user (or Add User).
- Paste the full
client_emailfrom your service account JSON file. - Set the permission level to Developer or Admin.
- Save.
⚠️ Common Trap: If you omit this step, any API call or pass creation will fail with
401 Unauthorized: Request had invalid authentication credentialsor403 Forbidden: Not an issuer, even if the Service Account is Project Owner in Google Cloud.
1.6 Pass Classes vs. Pass Objects
Google Wallet separates templates from individual user passes:
- Pass Class (Template): Defines the global layout, program name, brand logos, primary colors, review status, and callback URLs. Created once per brand/program.
-
Pass Object (Instance): Represents a single user's pass. References the
classIdand holds specific values (User's Name, Current Points Balance, Unique Barcode, Account ID).
Google supports several class types:
-
genericClass/genericObject: Best for flexible reward cards, club memberships, vouchers, and event tickets. Highly customizable. -
loyaltyClass/loyaltyObject: Specifically tailored for retail points programs with structured tier labels. -
eventTicketClass/eventTicketObject: Tailored for seated venue events with seat/gate numbers.
Class IDs follow the format: <Issuer_ID>.<custom_class_suffix> (e.g., 3388000000023066256.summer_rewards_class).
1.7 Generating the Save Link: The "Skinny" JWT Flow
You do not need to make an upfront REST API call to pre-create every object before a user saves it. Google provides an atomic, high-performance issuance pattern known as the Skinny JWT:
┌─────────────────┐ GET /google/pass/:id ┌─────────────────┐
│ User Browser │ ───────────────────────────────> │ Your Backend │
│ │ <─────────────────────────────── │ Signs JWT │
└─────────────────┘ 302 Redirect to Save URL └─────────────────┘
│
▼
┌─────────────────┐
│ Google Pay Save │ ──> Pass added to Google Wallet app instantly!
└─────────────────┘
JWT Structure
The backend constructs an RS256-signed JWT containing the pass object and returns a 302 redirect:
// JWT Header
{
"alg": "RS256",
"typ": "JWT"
}
// JWT Claims Payload
{
"iss": "wallet-issuer-sa@project-id.iam.gserviceaccount.com",
"aud": "google",
"typ": "savetowallet",
"iat": 1729600000,
"origins": [
"http://localhost:3000",
"https://your-domain.ngrok-free.dev"
],
"payload": {
"genericObjects": [
{
"id": "3388000000023066256.user-uuid-1234",
"classId": "3388000000023066256.summer_rewards_class",
"state": "ACTIVE",
"cardTitle": {
"defaultValue": { "language": "en-US", "value": "Reisty Rewards" }
},
"header": {
"defaultValue": { "language": "en-US", "value": "George Ikwegbu" }
},
"textModulesData": [
{
"id": "points",
"header": "POINTS",
"body": "120"
}
],
"barcode": {
"type": "QR_CODE",
"value": "<signed_qr_payload>",
"alternateText": "Scan at terminal"
}
}
]
}
}
⚠️ Critical Requirement: The
originsclaim is required by Google. It must match the domain or IP from which the user initiates the Save flow. For local and mobile testing, include your HTTPS tunnel domain (see Part 4).
The user is redirected to:
https://pay.google.com/gp/v/save/<signed_jwt>
When clicked on Android or desktop Chrome, Google verifies the cryptographic signature against your Service Account public key, provisions the object on their servers, and adds it to the user's Google Wallet immediately.
Part 2 — Apple Wallet: Developer Setup, PassKit & Certificates
Apple Wallet passes (.pkpass) are signed ZIP archives verified by iOS using Public Key Cryptography Standards (PKCS#7). iOS strictly validates the signature against Apple's Worldwide Developer Relations (WWDR) Certificate Authority.
APPLE PASSKIT SIGNING PIPELINE
pass.json + icon.png + logo.png
│
▼
Generate manifest.json (SHA-1 checksum of every asset)
│
▼
Sign manifest.json using Pass Certificate + Private Key + WWDR G4
(Generates detached PKCS#7 signature)
│
▼
Compress into ZIP archive named <user_name>_pass.pkpass
2.1 Apple Developer Setup
- Enroll in the Apple Developer Program (annual $99 membership).
- Locate your Team ID under Account → Membership Details (a 10-character alphanumeric string like
A1B2C3D4E5).
2.2 Register a Pass Type Identifier
- Go to Certificates, Identifiers & Profiles → Identifiers.
- Click +, select Pass Type IDs, and click Continue.
- Provide a Description (e.g.
Platform Loyalty Passes) and an Identifier formatted aspass.<organization>.<product>(e.g.pass.com.yourcompany.wallet). - Register the identifier.
2.3 Create & Export Signing Certificates
Passes must be signed by a certificate issued specifically for your Pass Type ID.
- In Keychain Access on macOS, select Certificate Assistant → Request a Certificate from a Certificate Authority.
- Enter your email, select Saved to disk, and leave CA Email blank. This creates a
CertificateSigningRequest.certSigningRequest(CSR).
- Enter your email, select Saved to disk, and leave CA Email blank. This creates a
- In the Apple Developer portal under Identifiers → Pass Type IDs, select your ID and click Create Certificate.
- Upload your CSR. Download the resulting
pass.cer. - Double-click
pass.certo install it into Keychain Access. > 💡 Keychain Tip: Ensure the certificate is installed in the login keychain, NOT the System keychain; otherwise, macOS will grey out the.p12export option. - In Keychain Access, right-click the Pass Type ID certificate and choose Export "Pass Type ID: ...".
- Export as
.p12format (e.g.pass.p12). Enter an export password — this is yourAPPLE_CERT_PASSPHRASE.
2.4 Convert to Server-Ready PEM Files
Convert the .p12 file and Apple’s root authority certificate into unencrypted PEM files for your backend:
# 1. Extract the public pass certificate
openssl pkcs12 -in pass.p12 -clcerts -nokeys -out passcertificate.pem
# 2. Extract the unencrypted private key
openssl pkcs12 -in pass.p12 -nocerts -nodes -out passkey.pem
# 3. Download Apple Worldwide Developer Relations (WWDR) G4 Certificate
curl -O https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer
# 4. Convert WWDR from DER to PEM
openssl x509 -inform DER -in AppleWWDRCAG4.cer -out wwdr.pem
Store passcertificate.pem, passkey.pem, and wwdr.pem in core/apple/certs/. Add this folder to .gitignore.
2.5 The .pkpass Package Anatomy & Mandatory Assets
A .pkpass bundle is a standard ZIP file with an exact file hierarchy:
George_Ikwegbu_pass.pkpass
├── pass.json ← Data structure, text fields, colors, serial number
├── icon.png ← MANDATORY: 29x29px (shown in iOS notifications & pass list)
├── icon@2x.png ← MANDATORY: 58x58px
├── icon@3x.png ← MANDATORY: 87x87px
├── logo.png ← MANDATORY: 160x50px (pass header face)
├── logo@2x.png ← MANDATORY: 320x100px
├── manifest.json ← SHA-1 checksum map of every file above
└── signature ← Detached PKCS#7 signature of manifest.json
⚠️ Strict Asset Rules:
- Apple PassKit will fail with
SyntaxError: Missing required image icon.pngificon.pngis absent from the bundle template.- Never include
manifest.jsonorsignatureinsidemanifest.json.
2.6 Apple PassKit Validation Rules & Gotchas
When building with libraries such as @walletpass/pass-js (Node), passkit-generator (Python), or passbook (Ruby), you must satisfy two critical Apple requirements:
-
authenticationTokenLength Requirement: Apple PassKit mandates that if a pass defines awebServiceURL, theauthenticationTokenmust be at least 16 characters long.
// ❌ FAILS: Throws TypeError: authenticationToken must be 16 characters or longer
pass.authenticationToken = 'auth-' + Math.random().toString(36).substring(7);
// ✅ CORRECT: Increase it
pass.authenticationToken = 'auth-' + Math.random().toString(36).substring(2) + Math.random().toString(36).substring(2);
// ✅ CORRECT: Cryptographically random 32-character hex token
pass.authenticationToken = crypto.randomBytes(16).toString('hex');
-
Apple Web Service Protocol Requires HTTPS:
Apple devices strictly refuse to register passes or fetch background updates if
webServiceURLuses plainhttp://. It must be HTTPS with a valid TLS certificate.
Part 3 — Testing Live Passes Locally via HTTPS Tunneling (ngrok)
Developing wallet passes locally presents a challenge:
- Apple Wallet on iOS requires a public HTTPS
webServiceURLto register passes and receive background push updates. - Google Wallet JWT tokens enforce an
originscheck and require publicly resolvable image URLs. - Testing on physical iPhones and Android devices requires your computer’s local dev server to be reachable from mobile devices.
This is resolved using an HTTPS tunnel such as ngrok.
┌─────────────────┐ HTTPS Requests ┌──────────────┐ Port 3000 ┌──────────────┐
│ Physical Phone │ ─────────────────────────> │ ngrok Tunnel │ ──────────────────> │ Local Server │
│ (iOS / Android) │ <───────────────────────── │ Public HTTPS │ <────────────────── │ (Nest / Exp) │
└─────────────────┘ └──────────────┘ └──────────────┘
3.1 Starting the Tunnel
Launch an HTTPS tunnel targeting your local backend server port (e.g. port 3000):
# Standard dynamic tunnel
ngrok http 3000
# Or using a persistent static domain (recommended)
ngrok http --url=https://your-domain.ngrok-free.dev 3000
ngrok will output a secure HTTPS endpoint:
https://your-domain.ngrok-free.dev -> http://localhost:3000
3.2 Configuring the Backend
Update your server configuration (e.g. core/.env):
PORT=3000
API_BASE_URL=https://your-domain.ngrok-free.dev
In your pass generation logic:
-
Google Wallet: Add
process.env.API_BASE_URLto the JWToriginsarray:
origins: [this.configService.get('API_BASE_URL'), 'http://localhost:3000']
- Apple Wallet: PassKit sets the registration URL dynamically:
const webServiceURL = `${this.configService.get('API_BASE_URL')}/api/apple`;
if (webServiceURL.startsWith('https://')) {
pass.webServiceURL = webServiceURL;
pass.authenticationToken = crypto.randomBytes(16).toString('hex');
}
Now, clicking pass generation links on physical devices or in your web dashboard allows iOS and Android to connect directly to your local workstation.
Part 4 — Interactive Pass Endpoints & Frontend UX
A clean user experience offers dedicated chips for both mobile ecosystems:
┌────────────────────────────────────────────────────────────────────────┐
│ USER POINTS PASSES ACTIONS │
├────────────────────────────────────────────────────────────────────────┤
│ James Groka ° 80 [ ● GOOGLE ] [ ● APPLE ] [⎘] [🗑] [⋮] │
└────────────────────────────────────────────────────────────────────────┘
4.1 Server Pass Endpoints
Google Pass Redirect Endpoint
GET /google/pass/:userId
- Looks up the user and active pass configuration in the database.
- Generates the signed Google JWT.
- Responds with
302 Foundredirecting directly tohttps://pay.google.com/gp/v/save/<jwt>. - Clicking this link with
target="_blank"on desktop or mobile opens the native Google Wallet save dialogue.
Apple Pass Download Endpoint
GET /apple/pass/:userId
- Looks up the user and active pass template.
- Injects the user’s name, points, barcode, and unique serial number into
pass.json. - Signs and compresses the
.pkpassbundle in memory. - Sanitizes the user's name for the download filename (e.g.
George_Ikwegbu_pass.pkpass). - Returns binary data with standard PassKit headers:
HTTP/1.1 200 OK
Content-Type: application/vnd.apple.pkpass
Content-Disposition: attachment; filename="George_Ikwegbu_pass.pkpass"
Cache-Control: no-store, no-cache, must-revalidate, max-age=0
4.2 Frontend Chip Design & Interaction
In your frontend dashboard (Vue, React, or standard HTML), implement two distinct pills matching platform conventions:
-
Google Chip: Light grey/white pill (
#F8FAFB), subtle border (border-slate-200), rounded rectangle (rounded-lg), uppercase bold label, and a blue indicator dot (#1A73E8).
<a
:href="`${apiBaseUrl}/google/pass/${user.id}`"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-3 py-1.5 bg-[#F8FAFB] hover:bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-bold text-slate-700 tracking-wider"
>
<span class="w-2 h-2 rounded-full bg-[#1A73E8] mr-2"></span>
GOOGLE
</a>
-
Apple Chip: Identical pill container with a dark slate/black indicator dot (
#0F172B).
<a
:href="`${apiBaseUrl}/apple/pass/${user.id}`"
:download="`${user.fullname.replace(/\s+/g, '_')}_pass.pkpass`"
class="inline-flex items-center px-3 py-1.5 bg-[#F8FAFB] hover:bg-slate-100 border border-slate-200 rounded-lg text-[11px] font-bold text-slate-700 tracking-wider"
>
<span class="w-2 h-2 rounded-full bg-[#0F172B] mr-2"></span>
APPLE
</a>
When clicked:
- The Google chip opens a new tab that immediately redirects into Google Wallet's cloud save prompt.
- The Apple chip triggers a browser download of the
.pkpassbundle on desktop, or launches the Apple Wallet installation sheet on iOS Safari.
Part 5 — Designing Passes & Tamper-Proof QR Codes
5.1 Barcodes & Cryptographic Redemptions
A common security vulnerability in loyalty passes is encoding raw customer IDs in the QR code (e.g. {"userId": "123"}). A user can screenshot their QR code or copy another customer’s ID and redeem unauthorized points.
Secure Tokenized QR Architecture:
- Server Signs a Short-Lived JWT for the QR:
{
"sub": "user-uuid-123",
"tenantId": "tenant-uuid-456",
"iat": 1729600000,
"exp": 1729603600
}
-
Scanner Validates Authenticity:
When the merchant scans the QR code at the terminal:
- Validates the cryptographic signature against the platform secret.
- Verifies
exphas not lapsed. - Atomically decrements points in the database.
- Triggers background pass balance updates to both Apple and Google.
5.2 Dynamic Pass Updates
When a customer’s balance updates:
-
Google Wallet: Execute a
PATCHrequest tohttps://walletobjects.googleapis.com/walletobjects/v1/genericObject/<objectId>updating the text modules. Google pushes an automated lock-screen update. -
Apple Wallet:
- Update the database record.
- Send an APNs background push notification containing an empty JSON body
{}to all registered device tokens for that pass serial number. - iOS wakes up silently, calls your server's
GET /v1/passes/:passTypeIdentifier/:serialNumber, and replaces the.pkpassbundle on device.
Part 6 — Multi-Tenant Architecture: One Platform, Hundreds of Brands
In a multi-tenant platform (SaaS for restaurants, fitness studios, or ticketing agencies), each business wants its own logo, colors, and rewards structure.
You do not need separate Apple Developer accounts or Google Cloud projects for each tenant. A single platform issuer can host hundreds of branded passes.
Your Platform (Issuer)
│ Google Issuer ID: 3388000000023066256
│ Apple Pass Type ID: pass.com.yourplatform.wallet
│
├── Tenant A: Restaurant Rosso
│ Google Class: 3388000000023066256.rosso_loyalty
│ Apple Template: templates/rosso/pass.json + red branding
│ Customers receive Rosso-branded passes
│
└── Tenant B: Café Verde
Google Class: 3388000000023066256.verde_loyalty
Apple Template: templates/verde/pass.json + green branding
Customers receive Verde-branded passes
6.1 Tenant Class ID Convention
Adopt deterministic namespacing tied to your internal database UUIDs:
Google Class ID: <Issuer_ID>.<tenant_uuid>_loyalty
Apple Template: storage/tenants/<tenant_uuid>/pass.json
Apple Icons/Logos: storage/tenants/<tenant_uuid>/logo.png
When a business updates its logo or primary color in your admin portal:
-
Google: Call
PATCH /walletobjects/v1/genericClass/<classId>with the new asset URLs. Google propagates the change to all existing customer passes automatically. - Apple: Update the tenant's asset directory and trigger APNs push notifications to update devices.
Part 7 — One Customer, Many Tenants: The Junction Model
Customers frequently visit multiple businesses on the same platform. A user might hold a loyalty pass for Restaurant Rosso and another for Café Verde.
7.1 Relational Data Model
Decouple the global user profile from their tenant-specific membership using a Junction Record:
User Account: george@example.com (Global ID: user-101)
│
├── Tenant Membership (Rosso)
│ points: 80
│ googleObjectId: 3388000000023066256.rosso_user_101
│ appleSerialNumber: rosso_user_101
│
└── Tenant Membership (Verde)
points: 30
googleObjectId: 3388000000023066256.verde_user_101
appleSerialNumber: verde_user_101
-- Relational Schema Representation
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email VARCHAR(255) UNIQUE NOT NULL,
fullname VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
);
CREATE TABLE tenant_memberships (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE,
points INTEGER DEFAULT 0,
google_pass_id VARCHAR(255),
apple_pass_id VARCHAR(255),
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
UNIQUE(user_id, tenant_id)
);
7.2 Idempotent Pass Issuance
To prevent duplicate passes when a user clicks "Add to Wallet" multiple times, employ an upsert pattern:
POST /passes/issue
Body: { userId, tenantId, platform }
1. Upsert tenant_memberships WHERE user_id = userId AND tenant_id = tenantId.
2. If membership already possesses a valid pass ID:
-> Return existing Google Save URL or stream existing Apple .pkpass.
3. If new membership:
-> Mint unique pass IDs: `${tenant_id}_${user_id}`
-> Persist pass ID in membership record.
-> Return generated Save URL / .pkpass.
Conclusion & Production Checklist
Building a unified wallet pass system bridges digital interaction with real-world physical verification. By structuring your backend around standards-compliant JWTs for Google Wallet, strictly validated PassKit packages for Apple Wallet, and an HTTPS tunnel for live testing, you create a robust, production-ready foundation.
Production Pre-Flight Checklist
- [ ] Google Service Account: Authorized as Developer/Admin in the Google Pay & Wallet Console.
- [ ] JWT Origins: Configured with your live production HTTPS domain.
- [ ] Apple Certificates: Extracted into
passcertificate.pem,passkey.pem, andwwdr.pem. - [ ] PassKit Asset Validation: Template contains
icon.png(29x29) andlogo.png(160x50) in all pixel densities. - [ ] PassKit Token:
authenticationTokenis verified to be $\ge 16$ characters. - [ ] Security &
.gitignore: All.pem,.p12, service account JSON files, and environment files (.env,.env.*) are untracked and excluded from version control, leaving only.env.exampleandpass.json.example. - [ ] Local Testing: Verified live pass installation on physical iOS and Android devices using ngrok HTTPS tunneling.
Open Source Repository & Community Contributions
The complete codebase backing this guide — including the backend pass generation controllers, Apple PassKit signing pipeline, and the Vue 3 Tailwind admin dashboard — is open-source on GitHub:
👉 https://github.com/gikwegbu/add_to_google_apple_wallet
We Welcome Contributions!
This project is open-source and actively evolving. If you would like to contribute:
- Fork the repository on GitHub.
- Submit Pull Requests for new features, bug fixes, automated pass push notifications, or documentation improvements.
- Open Issues to report bugs or suggest enhancements.
- If this project helped you or your team, consider giving it a ⭐ on GitHub!
Top comments (0)