All articles
// developers

How Apple Wallet Pass Updates Actually Work: The Web Service Protocol in Full

Registration, a silent push, and two GET requests: the protocol every Apple Wallet pass update runs on, and the step where most implementations quietly fail.

Updated
How Apple Wallet Pass Updates Actually Work: The Web Service Protocol in Full
Julio Song
10 min read

A developer we'll call Priya has shipped the easy part. Her airline's boarding pass signs correctly, the barcode scans at the gate, and a fresh install looks exactly like the mockup. Then a flight gets delayed forty minutes, the gate changes from C14 to A2, and every pass already sitting in a passenger's Wallet keeps showing the old gate. Nobody's phone rings, nothing crashes, and the only alert is a passenger complaint that support has no way to explain. The pass was never wrong. Nothing ever told it to change.

That gap is the entire second half of Apple Wallet development, and it's the half no generation library touches. Signing a .pkpass gets a pass onto a phone once. Getting it to change afterward means your server implements a protocol of its own: a device registers itself, your server pushes an empty notification through Apple's Push Notification service, and the device calls back to ask what's new. Here's the full sequence, the exact requests and responses at each step, and where it silently breaks.

Three parties, one shared secret

Every update runs through three players: the device, Apple's push infrastructure, and your web service. None of them talk directly to a passenger. Two fields in pass.json set the whole thing up. webServiceURL is the base address of your server, and it has to be HTTPS in production, since Wallet won't register a device against a plain HTTP endpoint. authenticationToken is a shared secret your server generates per pass, sent back on every request afterward so your server can confirm a request actually comes from a device holding that specific pass rather than someone guessing at a serial number.

That token is easy to get wrong in a way that costs you later. It's fine to change most fields in an update, seat number, gate, loyalty balance, but not the authentication token or the serial number. A device holding an older pass keeps sending the token it was issued at install time, and if your server only recognizes the newest one, every earlier install starts failing authentication for no reason a support ticket will make obvious.

Registering a device is not the same as installing a pass

Adding a pass to Wallet and registering it for updates are two separate events, and the second only happens if webServiceURL and authenticationToken are both present. When it does, the device sends:

POST /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}/{serialNumber}
Authorization: ApplePass {authenticationToken}
Content-Type: application/json

{ "pushToken": "a1b2c3d4..." }

Your server checks the token against what it issued for that serial number, and returns 401 if it doesn't match. On success, Apple's own guide documents a 200 OK, though most implementations in the wild also return 201 for a first-time registration and reserve 200 for a device re-registering the same pass, which is a useful distinction to keep on your side even where Apple's spec doesn't insist on it. What your server actually has to do is store two mappings: which push token belongs to which device, and which device is registered against which pass. A loyalty pass with ten thousand holders needs both directions, since a later update has to find every device registered for that one serial number, and a device switching phones needs its old registration cleaned up rather than accumulating stale rows next to the new one.

A push that says nothing

When Priya's gate change lands in the airline's database, her server doesn't send the new gate to the device directly. It sends an APNs push with an empty JSON payload, {}, to every push token registered for that pass. The push carries no pass data at all. Its only job is waking the device up.

Three headers matter more than the payload does. apns-topic has to be the pass type identifier, not an app bundle ID, since Wallet subscribes by pass type rather than by app. apns-push-type has to be background. apns-priority has to be 5, the value Apple reserves for background pushes, not the 10 used for a user-visible alert. And the push has to be signed with the same Pass Type ID certificate used to sign the pass itself, not whatever certificate an app on the same team might use for its own notifications. Get any of those three headers wrong and APNs still returns success. It accepted the push for delivery. Whether Wallet does anything with it once it arrives is a separate question APNs never answers for you, which is the single most common reason a working-looking integration produces a pass that never updates.

APNs also makes no delivery guarantee, and it coalesces repeated pushes to the same device rather than queuing every one, so hammering the same token with five updates in a minute can arrive as one wake-up rather than five. That's a feature for a server sending a burst of unrelated changes, and a trap if your update logic assumes every push corresponds to exactly one change.

The two GET requests that finish the job

A device that receives the push doesn't yet know what changed, only that something might have. It calls back into your service with two more requests, in order.

First, it asks which passes changed:

GET /v1/devices/{deviceLibraryIdentifier}/registrations/{passTypeIdentifier}?passesUpdatedSince={lastTag}
Authorization: ApplePass {authenticationToken}

Your server answers with the serial numbers that changed since that tag, plus a new one:

{ "serialNumbers": ["AA1180-2026-09-07"], "lastUpdated": "1725724800" }

That tag is opaque to Wallet. It never parses it as a timestamp or a version number, only compares two tags to tell which is later, so a monotonically increasing counter works exactly as well as a real timestamp does. What matters is that it always moves forward when something changes and never moves on a request that didn't change anything, since a tag that doesn't advance means the device concludes nothing happened and the gate change never reaches the passenger.

Second, for every serial number in that list, the device fetches the pass itself:

GET /v1/passes/{passTypeIdentifier}/{serialNumber}
Authorization: ApplePass {authenticationToken}

Your server returns a freshly signed .pkpass, complete with a new manifest and signature, since the old signature no longer matches a bundle with a different gate number in it. If the pass really hasn't changed since the device's last fetch, a 304 Not Modified against an If-Modified-Since header is the correct response and saves both sides the work of transferring and re-signing something identical. Only once this second request completes does the passenger actually see A2 on the lock screen. Everything before this point was setup.

Whether the passenger gets interrupted at all

Wallet doesn't announce every update the same way. It compares the pass it already has against the one your server just returned, field by field, and only surfaces a banner notification for fields that carry a changeMessage in pass.json, something like "Your gate has changed to %@". A field without one still updates silently on the lock screen next time the passenger glances at it, with no interruption at all.

That distinction matters more than it looks. A gate change or a flight delay is worth a banner, since a passenger needs to act on it before boarding closes. A loyalty balance ticking up by one stamp, or a phone number correction, isn't, and a service that attaches a change message to every field trains passengers to swipe the banner away without reading it, which defeats the one you actually need them to see. Apple's own guidance is blunt about this: reserve change messages for genuinely time-sensitive information, and let everything else update quietly.

Cleaning up after yourself

A passenger who deletes a pass triggers a DELETE to the same registration endpoint your server handled the original POST on, and a well-behaved server removes that device's registration for that pass, then removes the device entirely once it holds zero registrations. Skip this and your device table grows forever with entries nobody is listening on.

The messier version of the same problem shows up without a delete at all. Phones get restored from backup, reinstalled, or migrated to a new device, and each of those events can hand a device a fresh push token for a pass it already held. APNs will tell you when a push token stops working, and the right response is removing that device's record rather than retrying it. A server that never prunes invalid tokens keeps paying for pushes nobody receives, and worse, keeps believing an update reached a passenger who hasn't seen it in months.

Why "it worked in testing" doesn't prove it works in production

The iOS Simulator doesn't register real push tokens, so a flow that looks complete against it can still be missing pieces that only show up on a physical device. A few patterns account for most of the failures that reach production:

  • The push signs with the wrong certificate. A team with an existing app on the same developer account sometimes reaches for that app's push certificate out of habit. APNs accepts it and returns success. Wallet never acts on it, since the pass type identifier in apns-topic doesn't match what that certificate is authorized to push under.
  • The change tag doesn't move. A server that recomputes the same lastUpdated value for an unchanged field, or that ties the tag to something that doesn't actually change on every update, produces a device that keeps asking and keeps getting told nothing is new.
  • A stale push token stays in the table. The device reinstalled six months ago and re-registered with a new token. The old row never got removed, so updates keep going to a token nobody's listening on, while the phone that's actually in someone's pocket never gets woken up.
  • The Pass Type ID certificate expired. Every re-signed pass in the second GET request needs a valid certificate, the same one that expires annually and stops new signing without touching anything already installed. A pass that stopped updating last week and a certificate that lapsed last week are usually the same story.

None of these produce an error a passenger, or Priya, ever sees. APNs says success at every step. The device just never receives anything worth acting on. The only reliable way to catch any of them before a passenger does is logging your own side of the exchange, since the second GET request, the one that actually fetches the pass, tells you definitively whether a device followed through on a push. A push with no matching fetch within a few minutes is the signal worth alerting on, not the push's own 200 from APNs.

Where this leaves you building this yourself

Four endpoints, an empty push, and a change tag that has to behave correctly under concurrent updates: none of it is exotic, and all of it has to work together for a single gate change to reach a phone in someone's pocket. A device table, a registrations table, an APNs client with the right certificate, and a web service that stays reachable for as long as any pass you've ever issued might still be installed add up to real infrastructure most teams only realize they need after the first update quietly fails to arrive. None of the popular generation libraries implement any of it, since signing a pass and keeping one current are different problems with different lifetimes, and a library that stops at sign() has done the easier half.

If you'd rather not run that infrastructure yourself, Passmint handles device registration, APNs delivery, and the update endpoints behind a single API call, so pushing a gate change is one request rather than four.

Primary sources

Common questions

No. A pass sits exactly as it was signed until your server sends an APNs push to the device that holds it. Wallet does not poll your server on its own schedule.
Nothing. The payload is an empty JSON dictionary. It only wakes the device, which then calls back into your web service to ask what changed.
APNs confirms delivery to the device, not that Wallet acted on it. A wrong apns-topic, an expired push token, or a change tag that never advances all produce a 200 from APNs and a pass that never updates.
No. The push notification carries no pass data, only a wake-up signal. The device always calls back into your registrations and pass endpoints to fetch the actual update, even for a one-field change.
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