Wallet passes
Putting a loyalty card into Apple Wallet and Google Wallet, and what it takes to switch on
Wallet passes
A customer can keep their loyalty card in the phone's own wallet instead of installing an app. The card lives next to their payment cards, updates itself when the balance or tier changes, and can be handed over at the counter with the phone they already have in their hand.
This matters more than it sounds: installing an app is the single largest drop-off in onboarding a customer. A wallet pass removes it.
What the API gives you
| Endpoint | Auth | Returns |
|---|---|---|
GET /{locale}/loyalty-cards/{id}/wallet | customer token | Both links: an Apple .pkpass URL and a Google Wallet save URL |
GET /{locale}/wallet/apple/{id} | signed temporary URL | The .pkpass file itself |
The .pkpass download deliberately uses a signed, short-lived URL rather than the
bearer token: Safari and Chrome open the link without an Authorization header, so a
token-protected URL would simply fail. The link's lifetime is
WALLET_LINK_TTL_MINUTES (default 30).
Implementation: app/Http/Controllers/WalletPassController.php,
app/Services/Wallet/{AppleWalletPassService,GoogleWalletPassService,WalletCardData}.php.
Switching it on
The code ships ready. It stays inert until the certificates exist — with no credentials configured, the endpoints return an error rather than a pass. These are the credentials, and they can only be obtained by a person with access to the developer accounts.
Apple Wallet
- Apple Developer Program membership (the same one the iOS app uses).
- A Pass Type ID in the developer portal, e.g.
pass.lt.loyalty.card. - The Pass Type ID certificate exported as
.p12, with its password. - The Apple WWDR intermediate certificate as
.pem.
APPLE_WALLET_PASS_TYPE_ID=pass.lt.loyalty.card
APPLE_WALLET_TEAM_ID=XXXXXXXXXX
APPLE_WALLET_CERTIFICATE_PATH=/var/www/.../storage/app/wallet/pass.p12
APPLE_WALLET_CERTIFICATE_PASSWORD=…
APPLE_WALLET_WWDR_PATH=/var/www/.../storage/app/wallet/wwdr.pem
APPLE_WALLET_ORGANIZATION=Loyalty.ltGoogle Wallet
- An issuer account in the Google Pay & Wallet Console → the Issuer ID.
- A service account with Wallet API access, and its JSON key.
- The origins allowed to show the "Save to Google Wallet" button.
GOOGLE_WALLET_ISSUER_ID=3388000000000000000
GOOGLE_WALLET_SERVICE_ACCOUNT_EMAIL=wallet@project.iam.gserviceaccount.com
GOOGLE_WALLET_SERVICE_ACCOUNT_JSON=/var/www/.../storage/app/wallet/google.json
GOOGLE_WALLET_ORIGINS=https://loyalty.lt,https://partners.loyalty.ltKeep both key files outside the web root and outside git. storage/app/wallet/ is the
right place; it is already private.
Keeping the pass up to date
A pass that shows last week's balance is worse than no pass, so the update path is part of the feature rather than a later addition.
Apple. The pass carries a webServiceURL pointing at https://api.loyalty.lt/wallet
and an authenticationToken derived from the card id and APP_KEY — nothing to store,
nothing to rotate. iOS registers the device against that service, and every balance
change wakes it with an empty APNs push; the device then fetches the new pass itself.
The five endpoints Apple requires (register, unregister, serials, pass, log)
live under /wallet/v1/, outside the locale prefix, because Apple knows nothing about
our languages.
The push needs an APNs key. The .p8 already used for VoIP works — it is valid for the
whole team — so these are optional and only needed if you want a separate key:
APPLE_WALLET_APNS_TEAM_ID=
APPLE_WALLET_APNS_KEY_ID=
APPLE_WALLET_APNS_KEY_PATH=
APPLE_WALLET_APNS_PRODUCTION=trueGoogle. The "Save to Wallet" link only creates the object; after that it lives on
Google's side, so a balance change sends a PATCH to the Wallet API using the same
service account. A 404 back means the customer never added the card — not an error.
Both run from the SyncWalletPass queue job, fired by the points ledger on every
balance change, spending included. The queue worker must be running or passes will
quietly stay stale.
The back of the pass also shows the last five ledger entries and the update time, so a changed number has a reason next to it.
Only the Loyalty.lt card
Scanned third-party cards are deliberately refused (422). Their pass would carry our
branding and our card number, while the retailer's till expects their own code — a pass
that looks like it works and fails at the counter is worse than no button at all.
What changes for the customer
- The card is added from the app or from a link — no install required for the link path.
- Balance and expiry updates are pushed to the pass, so the customer sees the current state without opening anything.
- On iOS 26 and later the pass can be handed over by tapping the phone against the merchant's device, in the same tap that pays.
What it does not do
A wallet pass is not a payment instrument and not an identity document. It carries the card number, the balance we put on it, and the branding — nothing else. Redemption still goes through the normal Shop API call from the till.