An Apple Wallet loyalty card is a signed zip file
Rename a .pkpass file to .zip and open it. Inside you'll find a few PNGs, a JSON file called pass.json, a manifest, and a signature. That is the entire product. There is no app in there and no code that runs on the phone.
pass.json describes the card. For loyalty you use the storeCard style, and you fill in header, primary, secondary, auxiliary, and back fields. A stamp card is usually a storeCard whose primary field reads something like "6 of 10", plus a barcode block with a format of PKBarcodeFormatQR and a message your cashier's scanner can read. Background color, foreground color, and label color are hex values you set yourself.
The images matter more than people expect. You need icon.png at 1x, 2x, and 3x, a logo.png, and optionally a strip image behind the top of the card. Leave out icon.png and the pass will refuse to open at all, which is the most common first-time mistake in this whole pipeline.
The identifiers and the signature
Three identifiers tie the file to you. The passTypeIdentifier is a reverse-domain string like pass.com.yourshop.loyalty, registered in the Apple Developer portal. The teamIdentifier is your 10-character team ID. The serialNumber is unique per card, per pass type, and it is how you find that one customer later.
There is a fourth string that does the real work: authenticationToken. It has to be at least 16 characters, and it is effectively the password for that single card. Anyone holding it can pull an update for that serial, so generate it randomly per pass and never derive it from a phone number or a row ID.
Signing is mechanical. Hash every file in the bundle with SHA-1 and write the results into manifest.json. Produce a detached PKCS#7 signature over manifest.json using your Pass Type ID certificate and its private key, with Apple's WWDR intermediate certificate included in the chain. Then zip it. Edit a single pixel after hashing and iOS rejects the pass with no useful error message.
From QR scan to Add to Wallet
Here is the customer path on Waya, timed in taps. The customer scans the QR code on the table tent or the counter. They get one screen asking for a first name and a mobile number. They tap once, and iOS shows the Add to Wallet sheet. No account, no password, no app download.
Behind that tap, a server mints pass.json for that customer, signs the bundle, and returns it with the content type application/vnd.apple.pkpass. The content type is the whole trick. Send the wrong one and Safari quietly saves a file called something.zip, the Add sheet never appears, and the customer assumes your card is broken. Watch out for a CDN or proxy rewriting the header on you.
This handoff is also where real people drop out. The Add sheet is a second decision, made while standing at a counter, so every extra second costs installs. Our enrollment page ships only React and the form, not the dashboard bundle, for exactly that reason. Android phones get a Google Wallet pass instead, and anything else gets a plain web card.
How the card updates itself: the web service and APNs
One key in pass.json turns a static file into a live object: webServiceURL. As soon as the pass is added, iOS calls POST /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber} on that URL, sending a push token in the body and an Authorization header of "ApplePass " plus the card's authenticationToken. Store that push token against the serial number. If this call fails, nothing else in this section will ever happen.
When the stamp count changes, you send a push to that token through APNs with an empty payload. The APNs topic must be exactly the pass type identifier, not your bundle ID. The push carries no data at all. It is a knock on the door.
The phone answers by asking what changed. It calls GET /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier} with a passesUpdatedSince tag, gets back a list of serial numbers, then fetches GET /v1/passes/{passTypeIdentifier}/{serialNumber} with an If-Modified-Since header. You either return a freshly signed .pkpass or a 304. Return 401 for a bad token, and honor DELETE on the registration path when someone removes the card.
Two details decide whether the customer actually notices. A field only produces a lock-screen line if it carries a changeMessage containing the %@ placeholder, so a pass can update perfectly and look silent. And Apple posts its own diagnostics to POST /v1/log on your web service, which is the single most useful endpoint to implement when things go wrong.
Lock-screen relevance and geofences
Apple Wallet can surface a pass without any push at all. pass.json accepts a locations array of up to 10 coordinates, each with an optional maxDistance, and a relevantDate for time-based cards. When the phone is near one of those points, the card appears on the lock screen on its own. The operating system does this passively, with no notification send and no message allowance consumed.
On Waya, branch proximity is configured on the Branches screen. One point of confusion is worth stating plainly: the Address and Maps fields on the card itself are display text shown to the customer, not the geofence. If a merchant fills in the address and expects lock-screen reminders, nothing happens until the branch is added on the Branches screen.
Where this actually breaks
Signing-time failures all look identical from the outside, so check them in order. An expired Pass Type ID certificate is first, and it is nasty because already-installed cards keep working while every newly signed pass stops installing. Then a chain missing the WWDR intermediate, a manifest hash that no longer matches the bundle, macOS junk like .DS_Store or a __MACOSX folder zipped in by accident, and a missing icon.png.
Delivery failures are quieter. The wrong content type, a webServiceURL that isn't HTTPS with a certificate iOS trusts, an authenticationToken under 16 characters, or a registration endpoint returning a 500 that nobody reads. The last one is the worst kind of bug: enrollment looks fine for weeks, and you only learn that no device ever registered when a merchant asks why stamps aren't showing up on customers' phones.
Push failures come third. APNs returns BadTopic if the topic isn't exactly the pass type identifier, and 410 for tokens that are gone, which means you must delete that registration rather than retry it forever. Your APNs authentication key expires too, on its own schedule, separate from the pass certificate. Put both renewal dates in a calendar with a 30-day warning, because neither one fails loudly.
Google Wallet solves the same problem differently
Google Wallet has no bundle to sign on your side. You create a class, which is the template for a card, and an object, which is one customer's copy, through a REST API. The Add button is a signed JWT link. To change a balance you PATCH the object, and propagating that to the device is Google's job, not yours.
In practice that means running two pipelines with two sets of credentials and two expiry calendars, plus a web fallback page for desktop browsers and anything unusual. That is what a wallet platform is really selling: not the card design, but the three delivery paths and the boring renewal calendar behind them.
If you just want a loyalty card, not a pass pipeline
Most shop owners reading this do not want to own any of the above. Waya runs it: the certificates, the signing, the update web service, the push queue, and the Google Wallet and web fallbacks. You design the card, print the QR code, and stamp customers by scanning their card on an ordinary phone. There is no POS integration and no hardware, so Waya sits beside whatever till you already use.
The free plan is 0 SAR forever and covers up to 100 customers, 100 wallet messages a month, 1 stamp card, and 1 branch, with no credit card. It stops at those limits rather than billing you, and enrolled customers keep their cards. Growth is 85 SAR a month for unlimited customers, up to 10 cards, 3 branches, and 5,000 messages; Premium is 149 SAR a month with unlimited cards, branches, staff, and messages. There is also no SMS anywhere in the product, which is deliberate: wallet passes reach the lock screen without handing a customer's phone number to a messaging vendor.
For scale, the numbers our Arabic homepage publishes: 100+ shops across Saudi Arabia, 5,000+ customer cards living in wallets, and 4.9 out of 5 rated by 90 merchants.
Frequently asked questions
Do my customers need an app to use an Apple Wallet loyalty card?
No. Apple Wallet ships on every iPhone, so the card lands in an app your customer already has. On Waya, enrollment is a QR scan, a one-screen form asking for a first name and a mobile number, and one tap to add the pass. Android phones get a Google Wallet pass, and any other device gets a web card.
Why won't my .pkpass file open on an iPhone?
It is almost always one of four things: an expired Pass Type ID certificate, a manifest.json hash that no longer matches the files in the bundle, a missing icon.png, or a server sending the wrong content type instead of application/vnd.apple.pkpass. iOS gives you no error detail, so check them in that order. Renaming the file to .zip and looking inside takes ten seconds and usually finds it.
How fast does an Apple Wallet loyalty card update after a stamp?
Usually a few seconds. The chain is short: the stamp is saved, an empty push goes to the device through APNs, and the phone comes back and downloads a freshly signed pass. Apple's push delivery is best-effort, so Low Power Mode, a dead spot in a mall, or a phone that is switched off can stretch that to minutes or until the phone is next online.
Do I need an Apple Developer account to issue wallet passes?
Yes, if you are building the pipeline yourself, because a Pass Type ID and its signing certificate only come from the Apple Developer Program, which Apple charges 99 USD a year for at the time of writing. You also renew that certificate roughly every year, and an expired one silently stops new installs. On Waya the certificates, signing, and push infrastructure are ours, so you never touch them.
What happens after 100 customers on the free plan?
Enrollment stops at 100 customers and 100 wallet messages a month, and every customer already enrolled keeps their card and keeps collecting stamps. Nothing is deleted and no pass breaks. To keep enrolling, Growth at 85 SAR a month removes the customer cap and raises the message allowance to 5,000 a month.